diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..7b3b6bf5b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + timeout-minutes: 90 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6ee868152..007fcfec0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,25 +1,19 @@ # FreeBSD build and package check in a QEMU VM. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: - branches: [main] - paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "packaging/env.sh" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "packaging/env.sh" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh + schedule: + - cron: "17 4 * * *" workflow_dispatch: concurrency: @@ -35,5 +29,5 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: require-craft-fonts: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 run-package-check: true diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml index 344eae015..794c9affc 100644 --- a/.github/workflows/print.yml +++ b/.github/workflows/print.yml @@ -26,7 +26,7 @@ jobs: with: toolchain: stable components: clippy - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: desktop-print - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0cb9e9193..d1677e538 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ on: default: "" permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} @@ -61,12 +61,14 @@ jobs: release: needs: git-sources + permissions: + contents: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: app-name: vectorcraft app-title: VectorCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index f3ba1eeaa..2dc19466c 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -5,23 +5,17 @@ # on GitHub Actions' native windows-11-arm runner. name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "3 5 * * *" push: - branches: [main] - paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/windows-arm64.yml + - packaging/windows/** workflow_dispatch: concurrency: diff --git a/.github/workflows/windows7.yml b/.github/workflows/windows7.yml index 0d252f11e..7aa11a54b 100644 --- a/.github/workflows/windows7.yml +++ b/.github/workflows/windows7.yml @@ -41,7 +41,7 @@ jobs: with: toolchain: nightly-2026-10-01 components: rust-src - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: windows7-x64 - name: Build, check and test the portable package diff --git a/ASSETS.md b/ASSETS.md index 02c6d3b87..2d50ab95b 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -12,6 +12,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | | `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | @@ -177,6 +179,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-para-justify-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-ltr.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-para-numbers.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: the digits 1, 2 and 3, each before a line | MIT OR Apache-2.0 | | | `assets/icons/dc-para-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | @@ -349,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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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://crates.io/crates/hayro-cmap/0.1.0 | 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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/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/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) | +| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged | +| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged | diff --git a/Cargo.lock b/Cargo.lock index 7c6755fa7..8a86b9863 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,66 +4,60 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -222,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -437,6 +432,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -449,6 +468,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -709,7 +734,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -718,7 +743,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -740,6 +770,39 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -795,6 +858,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -805,6 +874,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -818,7 +897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -831,7 +910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -873,10 +952,20 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +dependencies = [ + "egui", + "serde", + "serde_json", +] + [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" dependencies = [ "hayro-interpret", "hayro-syntax", @@ -887,15 +976,20 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree 0.20.0", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "serde", @@ -904,12 +998,20 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "wayland-client", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -919,6 +1021,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -981,6 +1095,30 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dify" version = "0.8.0" @@ -1064,10 +1202,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1075,9 +1218,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1100,6 +1242,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1111,9 +1254,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1131,9 +1273,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1151,30 +1292,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1187,9 +1331,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1201,9 +1344,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1228,13 +1370,26 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1243,22 +1398,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1284,9 +1439,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1302,16 +1456,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1326,7 +1481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1391,6 +1546,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -1403,6 +1568,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1423,6 +1594,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1506,6 +1699,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1542,6 +1751,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1555,8 +1770,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1590,6 +1808,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1609,9 +1840,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1635,22 +1868,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1665,7 +1882,7 @@ dependencies = [ "png 0.18.1", "skrifa 0.44.0", "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1769,6 +1986,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1844,8 +2080,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" dependencies = [ "brotli", "hayro-postscript", @@ -1854,14 +2089,14 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" dependencies = [ "bitflags 2.13.2", "hayro-cmap", "hayro-syntax", "kurbo 0.13.1", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -1872,7 +2107,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -1890,13 +2125,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" +source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" dependencies = [ "flate2", "hayro-ccitt", @@ -1909,6 +2143,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1921,6 +2161,131 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2137,6 +2502,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-docker" version = "0.2.0" @@ -2285,18 +2656,18 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" +source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2448,6 +2819,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "md-5" version = "0.10.6" @@ -2482,6 +2859,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2502,6 +2885,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2530,6 +2924,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -2981,6 +3381,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3026,7 +3432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3070,6 +3476,25 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3106,8 +3531,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3118,7 +3553,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3127,8 +3572,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3143,6 +3601,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -3300,6 +3767,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3371,6 +3862,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -3398,23 +3946,39 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ + "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3543,6 +4107,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -3593,6 +4200,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3652,7 +4273,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", ] [[package]] @@ -3706,6 +4402,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -3715,6 +4417,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3738,6 +4449,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3750,6 +4484,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -3793,6 +4533,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3813,6 +4564,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3840,6 +4614,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4028,6 +4971,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4070,6 +5023,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4082,6 +5056,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -4102,6 +5082,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4124,6 +5114,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -4135,6 +5134,38 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4145,7 +5176,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4274,13 +5305,63 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -4334,6 +5415,51 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4377,6 +5503,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4493,6 +5625,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4732,6 +5876,7 @@ dependencies = [ "log", "proptest", "roxmltree 0.21.1", + "rustix 1.1.5", "ruzstd", "serde", "serde_json", @@ -4920,8 +6065,8 @@ dependencies = [ "vectorcraft-plugins", "vectorcraft-testkit", "vectorcraft-text", - "vello_common 0.2.0", - "vello_cpu 0.2.0", + "vello_common", + "vello_cpu", "wat", "weezl", ] @@ -4992,7 +6137,7 @@ dependencies = [ "unicode-segmentation", "vectorcraft-doc", "vectorcraft-geom", - "vello_cpu 0.2.0", + "vello_cpu", "windows-registry", "write-fonts", ] @@ -5038,6 +6183,7 @@ name = "vectorcraft-ui-egui" version = "0.16.0" dependencies = [ "bytemuck", + "craft-keymap", "craft-ui", "egui", "egui_extras", @@ -5088,21 +6234,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -5119,18 +6250,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_cpu" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" -dependencies = [ - "bytemuck", - "glifo 0.2.0", - "hashbrown 0.17.1", - "vello_common 0.1.0", -] - [[package]] name = "vello_cpu" version = "0.2.0" @@ -5139,13 +6258,13 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo 0.3.0", + "glifo", "hashbrown 0.17.1", "ordered-channel", "png 0.18.1", "rayon", "thread_local", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -5173,6 +6292,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5502,6 +6636,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5697,7 +6840,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5915,8 +7058,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5927,7 +7069,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5973,6 +7115,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + [[package]] name = "winresource" version = "0.1.34" @@ -6064,6 +7227,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -6089,6 +7287,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -6284,6 +7488,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -6386,3 +7610,18 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index c6a4b4521..ba466c0fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,13 +11,22 @@ rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] } -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] } +# Kept at the egui-0.37 port revision on purpose: 25da contains 4f85efb's bounded print +# parsing and 2bbef53's printer names; d991bf67 (VectorCraft main's pin) lacks both. This is a +# targeted security/UI bump, not an all-libs flatten. Re-check if main backports them. +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +# egui 0.37 port. 25da has craft_ui::capture and the old docking surface +# (DockContent/show_customized; Floating {panels, active, rect}) that VectorCraft's +# panel_docking.rs uses. Do not bump this to the new-docking L revision without porting that file. +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } +# The egui 0.37 port of craft-keymap. 25da's core keymap API matches 2eca9b3a; only its +# `ui` feature and tests differ, and VectorCraft enables only `egui`. +craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } @@ -101,10 +110,10 @@ tiff = "0.11" zune-jpeg = "0.5" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" arboard = { version = "3.6", default-features = false } clipboard-win = "5.4" @@ -124,18 +133,39 @@ todo = "deny" unimplemented = "deny" unreachable = "deny" +# Workspace crates keep line tables, so panics and backtraces name file and line; dependencies +# carry no debug info. Full debug info would make the dev target dir several times larger. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" } -krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 5cf3c8952..18bf7bf6f 100644 --- a/NOTICE +++ b/NOTICE @@ -75,12 +75,12 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Shared PDF backend -The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 -are fetched from https://github.com/storytold/craft-libs at revision -ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is +The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0 +and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision +2eca9b3a53a98ac52efed2a27353c6a6927ead10. Their upstream project is 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/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md. +https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/README.craft-reader.md. Assets already bundled through Hayro's default features retain their licences: - Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved. @@ -123,3 +123,21 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs similarity index 99% rename from apps/vectorcraft-cli/tests/cli.rs rename to apps/vectorcraft-cli/tests/it/cli.rs index e0d035e87..e06444b39 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -1,5 +1,3 @@ -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::Write; use std::process::{Command, Stdio}; diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs similarity index 97% rename from apps/vectorcraft-cli/tests/fileio.rs rename to apps/vectorcraft-cli/tests/it/fileio.rs index 4de10d932..104aeb0fe 100644 --- a/apps/vectorcraft-cli/tests/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -1,6 +1,4 @@ //! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::process::Command; diff --git a/apps/vectorcraft-cli/tests/it/main.rs b/apps/vectorcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..6918ea19b --- /dev/null +++ b/apps/vectorcraft-cli/tests/it/main.rs @@ -0,0 +1,7 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod cli; +mod fileio; +mod mcp_e2e; diff --git a/apps/vectorcraft-cli/tests/mcp_e2e.rs b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs similarity index 98% rename from apps/vectorcraft-cli/tests/mcp_e2e.rs rename to apps/vectorcraft-cli/tests/it/mcp_e2e.rs index e75ef0940..5a353070a 100644 --- a/apps/vectorcraft-cli/tests/mcp_e2e.rs +++ b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs @@ -1,6 +1,4 @@ //! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index c30799475..796680854 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -106,6 +106,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { - match target { - DropTarget::Place(d) => { - place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) }) - } - DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), - } - ctx.request_repaint(); - } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); - } - } - app.logic(ctx); + app.tick(ctx); crate::api::poll_replies(); + app.finish_ime_logic(ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { raw.events.extend(self.host.pointers.borrow_mut().take()); if let Some(app) = &mut self.app { - app.raw_input_hook(raw); + app.raw_input_hook(ctx, raw); } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); if let Some(app) = &mut self.app { + // DOM paste and drop are host input: read them in the UI phase, once per pass, not in + // `logic` where a hidden tab replays the last shown frame's input. + let pasted = self.host.pasted.borrow_mut().flavour.take(); + if let Some((f, held)) = pasted { + app.paste_from_host(&ctx, f, held); + } + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + if !dropped.is_empty() { + let at = self.host.drag.take(); + let z = ctx.zoom_factor(); + let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2)); + for f in dropped { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + let target = app.drop_target(&name, pos, shift); + let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + match f.bytes_async().await { + Ok(bytes) => { + match target { + DropTarget::Place(d) => { + place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) }) + } + DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), + } + repaint.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + } app.ui(ui); } crate::api::poll_replies(); diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 86235afbb..77b73a15a 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -23,7 +23,7 @@ vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) # so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217. craft-window = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } +eframe = { workspace = true, features = ["clipboard", "default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -62,8 +62,8 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_U muda = { version = "=0.21.1", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] } -# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd. -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] } +# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd. +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] } block2 = "0.6" # The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR # Apache-2.0 OR Zlib, already in the build through eframe and winit. diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index 529100513..e163ffb95 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -67,10 +67,31 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }; eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}"); + #[cfg(target_os = "macos")] + keep_answering(); std::thread::spawn(move || accept_loop(listener, tx, ctx)); rx } +/// The activity the control channel holds on macOS: work the user asked for, which may let the +/// system sleep when idle, with full timer precision. Without it, App Nap lowers every thread to +/// the lowest priority within about a minute of the window no longer being visible. +#[cfg(target_os = "macos")] +fn activity_options() -> objc2_foundation::NSActivityOptions { + use objc2_foundation::NSActivityOptions; + NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical +} + +/// Begins the control channel's activity. It lasts until the process ends. +#[cfg(target_os = "macos")] +fn keep_answering() { + use objc2_foundation::{NSProcessInfo, NSString}; + let reason = NSString::from_str("Answering control requests"); + let activity = NSProcessInfo::processInfo().beginActivityWithOptions_reason(activity_options(), &reason); + // The token is never released or passed to `endActivity`, so the activity lasts for the process's lifetime. + std::mem::forget(activity); +} + fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { let open = Arc::new(AtomicUsize::new(0)); for stream in listener.incoming().flatten() { @@ -642,4 +663,17 @@ mod tests { 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/mac_tablet.rs b/apps/vectorcraft/src/mac_tablet.rs new file mode 100644 index 000000000..b624262aa --- /dev/null +++ b/apps/vectorcraft/src/mac_tablet.rs @@ -0,0 +1,275 @@ +//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a +//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's +//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]). +//! +//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with +//! each event it is about to dispatch" in this app: +//! ) +//! reads each mouse and tablet event on the main thread before winit's view handles it, and passes +//! it on unchanged: +//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure` +//! (0..1) goes into the feed, kept for the pointer move winit makes of the event +//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event +//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`. +//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move. +//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says +//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so, +//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck +//! (`NSPointingDeviceTypeCursor`) is a mouse. +//! +//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which +//! merges the drags that queue up between two reads into one: every report of the pen arrives. +//! +//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the +//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor +//! reads them. +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; + +/// `NSEventType` values (AppKit headers). +mod event_type { + pub const TABLET_POINT: usize = 23; + pub const TABLET_PROXIMITY: usize = 24; + /// Left, right and other mouse down, up, moved and dragged: winit makes a pointer move of each. + pub const MOUSE: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 25, 26, 27]; +} + +/// `NSEventSubtype` values of mouse events. +mod subtype { + pub const TABLET_POINT: i16 = 1; + pub const TABLET_PROXIMITY: i16 = 2; +} + +/// `NSPointingDeviceType` values. +mod device { + pub const CURSOR: usize = 2; + pub const ERASER: usize = 3; +} + +/// The tablet fields of one `NSEvent`, as plain values. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct RawEvent { + /// `type`. + kind: usize, + /// `subtype` (mouse events only). + subtype: i16, + /// `pressure` (tablet point events and mouse events of that subtype only). + pressure: f32, + /// `pointingDeviceType` (proximity events only). + device: usize, + /// `isEnteringProximity` (proximity events only). + entering: bool, +} + +impl RawEvent { + fn mouse(&self) -> bool { + event_type::MOUSE.contains(&self.kind) + } + + fn proximity(&self) -> bool { + self.kind == event_type::TABLET_PROXIMITY || (self.mouse() && self.subtype == subtype::TABLET_PROXIMITY) + } + + fn point(&self) -> bool { + self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT) + } +} + +/// The events the monitor reads (`NSEventMask` bits are `1 << type`). +fn event_mask() -> u64 { + event_type::MOUSE.iter().chain(&[event_type::TABLET_POINT, event_type::TABLET_PROXIMITY]).fold(0, |m, t| m | 1u64 << t) +} + +/// What an event means for the feed. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Update { + /// Nothing: an event the monitor doesn't map. + Keep, + /// The pressure and end of a pen, or `None` for a mouse. + Set(Option), + /// A pen's sample that comes with a pointer move. + Move(PenSample), +} + +impl Update { + fn apply(self, feed: &StylusFeed) { + match self { + Update::Keep => {} + Update::Set(sample) => feed.set_sample(sample), + Update::Move(sample) => feed.set_move(sample), + } + } +} + +/// Which end of the pen is near the tablet. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Pen { + eraser: bool, +} + +impl Pen { + fn handle(&mut self, e: &RawEvent) -> Update { + if e.proximity() { + // Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse. + self.eraser = e.entering && e.device == device::ERASER; + let pen = e.entering && e.device != device::CURSOR; + return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser })); + } + let sample = PenSample { pressure: e.pressure, eraser: self.eraser }; + match (e.point(), e.mouse()) { + (true, true) => Update::Move(sample), + (true, false) => Update::Set(Some(sample)), + (false, true) => Update::Set(None), + (false, false) => Update::Keep, + } + } +} + +/// Start reading pen pressure: the feed the monitor writes into, or `None` (logged) when AppKit +/// refuses the monitor or this isn't the main thread. +#[cfg(target_os = "macos")] +pub fn start() -> Option { + // The monitor writes on the main thread before winit hands the event on: a press never waits. + let feed = StylusFeed::on_ui_thread(); + match monitor::install(feed.clone()) { + Ok(()) => Some(feed), + Err(e) => { + log::warn!("tablet pressure: {e}"); + None + } + } +} + +#[cfg(target_os = "macos")] +mod monitor { + use std::cell::Cell; + use std::ptr::NonNull; + + use block2::RcBlock; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSEvent, NSEventMask}; + use vectorcraft_ui_egui::stylus::StylusFeed; + + use super::{Pen, RawEvent, event_mask}; + + /// Install the monitor for the life of the app. Main thread only. + pub(super) fn install(feed: StylusFeed) -> Result<(), &'static str> { + MainThreadMarker::new().ok_or("the event monitor can only be installed from the main thread")?; + let pen = Cell::new(Pen::default()); + let handler = RcBlock::new(move |event: NonNull| -> *mut NSEvent { + // SAFETY: AppKit calls the handler with the event it is about to dispatch, which it + // keeps alive while the handler runs. + #[allow(unsafe_code)] + let e = unsafe { event.as_ref() }; + let mut p = pen.get(); + p.handle(&read(e)).apply(&feed); + pen.set(p); + event.as_ptr() + }); + // SAFETY: the handler returns the event it was given, a valid pointer ("the block's return + // must be a valid pointer or null"). AppKit copies the block and keeps it while the monitor + // is installed. + #[allow(unsafe_code)] + let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask(event_mask()), &handler) }; + // The monitor stays until the app quits: its token is never handed to `removeMonitor:`. + std::mem::forget(token.ok_or("AppKit refused the event monitor")?); + NSEvent::setMouseCoalescingEnabled(false); + Ok(()) + } + + /// The tablet fields of `e`. `subtype` raises an exception for events without one, and the + /// pressure and proximity fields are read only for the events that define them. + fn read(e: &NSEvent) -> RawEvent { + let mut raw = RawEvent { kind: e.r#type().0, ..Default::default() }; + if raw.mouse() { + raw.subtype = e.subtype().0; + } + if raw.point() { + raw.pressure = e.pressure(); + } + if raw.proximity() { + raw.device = e.pointingDeviceType().0; + raw.entering = e.isEnteringProximity(); + } + raw + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DRAG: usize = 6; + const KEY_DOWN: usize = 10; + + fn pen(kind: usize, pressure: f32) -> RawEvent { + RawEvent { kind, subtype: subtype::TABLET_POINT, pressure, ..Default::default() } + } + + fn proximity(device: usize, entering: bool) -> RawEvent { + RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() } + } + + fn tip(pressure: f32) -> PenSample { + PenSample { pressure, eraser: false } + } + + #[test] + fn a_pens_mouse_events_are_moves_with_its_pressure() { + let mut p = Pen::default(); + assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5))); + assert_eq!(p.handle(&pen(1, 0.25)), Update::Move(tip(0.25)), "a press"); + assert_eq!(p.handle(&pen(event_type::TABLET_POINT, 0.8)), Update::Set(Some(tip(0.8))), "no move"); + } + + #[test] + fn other_mouse_events_are_a_mouse_and_other_events_are_kept() { + let mut p = Pen::default(); + for subtype in [0, 3, 99, -1] { + let e = RawEvent { kind: DRAG, subtype, pressure: 1.0, ..Default::default() }; + assert_eq!(p.handle(&e), Update::Set(None), "subtype {subtype}"); + } + for kind in [0, KEY_DOWN, 22, 29, 34, usize::MAX] { + // Other events' subtypes don't make them a pen's. + let e = RawEvent { kind, subtype: subtype::TABLET_POINT, ..Default::default() }; + assert_eq!(p.handle(&e), Update::Keep, "type {kind}"); + } + } + + #[test] + fn proximity_says_which_end_is_near() { + let mut p = Pen::default(); + let eraser = |pressure| PenSample { pressure, eraser: true }; + assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0)))); + assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4))); + assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left"); + assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4))); + assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip"); + assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip"); + assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse"); + let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() }; + assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype"); + } + + #[test] + fn the_feed_gets_each_moves_pressure_within_0_to_1() { + let feed = StylusFeed::default(); + let mut p = Pen::default(); + for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] { + p.handle(&e).apply(&feed); + } + assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3))); + p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed); + assert_eq!(feed.held(), None, "a mouse moved"); + } + + #[test] + fn the_mask_covers_the_mapped_events() { + let m = event_mask(); + for t in [1, 2, 3, 4, 5, 6, 7, 23, 24, 25, 26, 27] { + assert!(m & (1 << t) != 0, "{t}"); + } + assert_eq!(m & (1 << KEY_DOWN), 0, "keys are not read"); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 3896f11a4..2a5eaf557 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -41,6 +41,7 @@ mod mac_menu; mod mac_save_format; #[cfg(target_os = "macos")] mod mac_screen_color; +mod mac_tablet; #[cfg(target_os = "macos")] mod mac_window; #[cfg(target_os = "macos")] @@ -88,8 +89,9 @@ impl eframe::App for App { // (`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); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + 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() { @@ -102,7 +104,7 @@ impl eframe::App for App { // A pen contact with the barrel button held presses the secondary button (Windows Ink). #[cfg(all(windows, not(target_vendor = "win7")))] windows_stylus::raw_input_hook(raw); - file_access::unconfined(|| self.app.raw_input_hook(raw)); + 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) { @@ -163,7 +165,7 @@ impl App { } open_files(&mut self.app, files); } - self.app.logic(ctx); + 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); @@ -309,15 +311,20 @@ fn save_prefs(app: &VectorcraftApp) { } if let Some(p) = prefs_path() { let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - let mut ui = app.ui.clone(); - ui.engine_prefs = app.session.prefs.to_json(); - if let Ok(bytes) = serde_json::to_vec_pretty(&ui) { + 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>; @@ -580,6 +587,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services { no_cursor_theme: wayland && cursor_theme::theme_missing(), #[cfg(target_os = "linux")] stylus: if x11 { tablet::start() } else { None }, + // macOS reads it from AppKit's events (#852). + #[cfg(target_os = "macos")] + stylus: mac_tablet::start(), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), @@ -1005,6 +1015,7 @@ fn main() -> std::process::ExitCode { run( options, Box::new(move |cc| { + VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx); let mut session = Session::new(); // The profiles loaded before come back before the preferences name any of them. prefs_dir::attach_profile_store(&mut session); @@ -1140,6 +1151,38 @@ mod tests { 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(); @@ -1201,6 +1244,17 @@ mod tests { ); } + #[test] + fn the_image_trace_preview_setting_is_kept_across_a_restart() { + for preview in [false, true] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.image_trace_preview = preview; + let saved = prefs_bytes(&app).unwrap(); + let loaded = decode_saved_prefs(&saved).unwrap().sanitized(); + assert_eq!(loaded.image_trace_preview, preview, "Preview {preview} after a restart"); + } + } + #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { use craft_window::{Frame, Layout}; diff --git a/apps/vectorcraft/src/printing.rs b/apps/vectorcraft/src/printing.rs index 643e64c69..c9e6539a2 100644 --- a/apps/vectorcraft/src/printing.rs +++ b/apps/vectorcraft/src/printing.rs @@ -23,6 +23,11 @@ impl PrintService for SystemPrint { fn shared_print(&self) -> Option { Some(std::sync::Arc::new(|job| SystemPrint.print(job))) } + fn turns_landscape(&self) -> bool { + // macOS's `lp` doesn't turn a landscape page to the paper: it cuts it off on upright + // paper. craft-print turns each sheet to its own orientation on Windows. + !cfg!(target_os = "macos") + } fn has_setup(&self) -> bool { true } @@ -127,3 +132,14 @@ mod tests { assert_eq!(args, ["ms-settings:printers"]); } } + +#[cfg(test)] +mod turn_tests { + use super::SystemPrint; + use vectorcraft_ui_egui::print::PrintService; + + #[test] + fn only_macos_queues_need_landscape_pages_turned() { + assert_eq!(SystemPrint.turns_landscape(), !cfg!(target_os = "macos")); + } +} diff --git a/apps/vectorcraft/tests/automation_args.rs b/apps/vectorcraft/tests/it/automation_args.rs similarity index 96% rename from apps/vectorcraft/tests/automation_args.rs rename to apps/vectorcraft/tests/it/automation_args.rs index 8156ff678..93b33fa16 100644 --- a/apps/vectorcraft/tests/automation_args.rs +++ b/apps/vectorcraft/tests/it/automation_args.rs @@ -1,6 +1,3 @@ -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; diff --git a/apps/vectorcraft/tests/help.rs b/apps/vectorcraft/tests/it/help.rs similarity index 92% rename from apps/vectorcraft/tests/help.rs rename to apps/vectorcraft/tests/it/help.rs index 5c77ecdbb..7c7624031 100644 --- a/apps/vectorcraft/tests/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -1,6 +1,3 @@ -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - //! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a //! terminal, SSH or an agent's shell it used to open the window (or hang without a display). diff --git a/apps/vectorcraft/tests/it/main.rs b/apps/vectorcraft/tests/it/main.rs new file mode 100644 index 000000000..d9dcf39cb --- /dev/null +++ b/apps/vectorcraft/tests/it/main.rs @@ -0,0 +1,6 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod automation_args; +mod help; diff --git a/assets/icons/dc-para-numbers.svg b/assets/icons/dc-para-numbers.svg new file mode 100644 index 000000000..27eb3cc2b --- /dev/null +++ b/assets/icons/dc-para-numbers.svg @@ -0,0 +1 @@ + diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 8986dae57..62e2974d2 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -92,10 +92,10 @@ text-frame fixture. ## Validation -* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread, +* `engine/tests/it/affinity_features.rs` covers pages, several artboards per canvas and per spread, master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded documents (whose artboards don't become artboards) on synthetic documents, and mutates them. - `engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail + `engine/tests/it/affinity_local.rs` compares every artboard of local documents with their thumbnail when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed). * Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries, checksums, budgets, cycles, hostile streams, every truncation) and property tests of random @@ -106,7 +106,7 @@ text-frame fixture. feature probes plus one separately identified derived regression input from [SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance notices were inspected, never that project's importer or authoring scripts. -* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current +* `tests/it/real_files.rs` parses every file and freezes the modeled features of all 33 current `.af` files. Native raster tests compare decoded pixels with independent embedded images; text tests assert Unicode run boundaries, fallback fonts and feature retention. * `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft` @@ -123,7 +123,7 @@ text-frame fixture. documents with layer effects, colour overlays (also over tables), line styles, paragraph and character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding and the embedded stream), and checks that whatever opens also renders and exports. - `tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million + `tests/it/import_limits.rs` builds hostile documents against the limits below: grids of 16 million cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object stream (`stream`): diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 5d74638ad..8e84e4807 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -215,7 +215,13 @@ fn anchors(records: &[Value]) -> Option> { fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option { let first = anchors.first()?; if let Some(radii) = radii.filter(|r| !r.is_empty()) { - let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max); + // The largest radius of each record, once (not a scan of all of them for each anchor). + let mut largest: std::collections::HashMap = std::collections::HashMap::new(); + for (rec, r) in radii { + let e = largest.entry(*rec).or_insert(0.0); + *e = e.max(*r); + } + let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0); let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p)); if polyline { let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect(); diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 5a1feaab4..7f10f8823 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -517,6 +517,9 @@ struct ReadAt { /// 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, @@ -692,7 +695,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); - let name = s.str(id, b"Desc").unwrap_or_default().to_string(); + // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 3eea67795..8f9e424aa 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -139,6 +139,14 @@ fn floats(v: &Value) -> Option> { /// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { + color_within(r, id, 0) +} + +/// The most spot colours that stand for one another followed. +const MAX_SPOT_CHAIN: u32 = 8; + +/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here. +fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option { let s = r.s; let class = s.class(id)?; let col = || -> Option> { @@ -170,7 +178,13 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { } b"Pant" => { r.warn("spot colours (imported as their RGB equivalent)"); - return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c))); + if depth >= MAX_SPOT_CHAIN { + return None; + } + return s + .obj(id, b"srgb") + .and_then(|c| color_within(r, c, depth + 1)) + .or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1))); } b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, _ => { diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index a755747c4..7a3010251 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -303,6 +303,10 @@ impl Parser<'_> { 0x2c => { let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; let size = usize::from(self.c.u16()?); + // Records of no bytes take nothing from the data, whatever their number. + if n > 0 && size == 0 { + return Err(Error::Malformed("array of records without bytes")); + } if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { return Err(Error::Malformed("array longer than its data")); } diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 817012fa7..eeb709d34 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -1,4 +1,4 @@ -//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents. +//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents. mod preview { use crate::container::header; diff --git a/crates/affinity/tests/import_limits.rs b/crates/affinity/tests/it/import_limits.rs similarity index 97% rename from crates/affinity/tests/import_limits.rs rename to crates/affinity/tests/it/import_limits.rs index 9a955116f..088c33912 100644 --- a/crates/affinity/tests/import_limits.rs +++ b/crates/affinity/tests/it/import_limits.rs @@ -2,14 +2,8 @@ //! stories, paragraphs and embedded documents stay within the whole read's budget, however often //! a file links or embeds the same content. Original synthetic documents in the reader's //! accepted schema; no Affinity software or files were used. -#![cfg(feature = "synth")] -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod support { - pub mod text_fixture; -} - -use support::text_fixture::*; +use crate::text_fixture::*; use vectorcraft_affinity::{ Document, Error, Limits, model::{Kind, Node}, diff --git a/crates/affinity/tests/it/main.rs b/crates/affinity/tests/it/main.rs new file mode 100644 index 000000000..84b343019 --- /dev/null +++ b/crates/affinity/tests/it/main.rs @@ -0,0 +1,20 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "../support/corpus.rs"] +mod corpus; +#[path = "../support/features.rs"] +mod features; +#[cfg(feature = "synth")] +#[path = "../support/text_fixture.rs"] +mod text_fixture; + +#[cfg(feature = "synth")] +mod import_limits; +#[cfg(feature = "synth")] +mod outline_limits; +mod raster; +mod real_files; +#[cfg(feature = "synth")] +mod text; diff --git a/crates/affinity/tests/outline_limits.rs b/crates/affinity/tests/it/outline_limits.rs similarity index 98% rename from crates/affinity/tests/outline_limits.rs rename to crates/affinity/tests/it/outline_limits.rs index b48eb494b..74151c192 100644 --- a/crates/affinity/tests/outline_limits.rs +++ b/crates/affinity/tests/it/outline_limits.rs @@ -1,7 +1,5 @@ //! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's //! accepted schema, without using Affinity software, third-party parser code or external assets. -#![cfg(feature = "synth")] -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use vectorcraft_affinity::{ Document, Limits, diff --git a/crates/affinity/tests/raster.rs b/crates/affinity/tests/it/raster.rs similarity index 97% rename from crates/affinity/tests/raster.rs rename to crates/affinity/tests/it/raster.rs index 173bc9158..794b4c4f1 100644 --- a/crates/affinity/tests/raster.rs +++ b/crates/affinity/tests/it/raster.rs @@ -1,7 +1,6 @@ //! Pixel retention on current Affinity 3 documents published under MIT by Patchy. //! Assertions use this reader's archive/stream and the original embedded image bytes; //! no third-party Affinity parser, source code or application is used as an oracle. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use vectorcraft_affinity::{ Archive, Limits, @@ -9,8 +8,7 @@ use vectorcraft_affinity::{ stream, }; -#[path = "support/corpus.rs"] -mod corpus; +use crate::corpus; fn images(nodes: &[Node], out: &mut Vec) { for node in nodes { diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/it/real_files.rs similarity index 93% rename from crates/affinity/tests/real_files.rs rename to crates/affinity/tests/it/real_files.rs index 66beca842..a1d72bd01 100644 --- a/crates/affinity/tests/real_files.rs +++ b/crates/affinity/tests/it/real_files.rs @@ -2,16 +2,13 @@ //! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by //! `AFFINITY_SAMPLES` (searched recursively). CI requires a complete, checksum-verified pinned //! corpus via `AFFINITY_CORPUS_REQUIRED=1`; ordinary local runs may skip an absent corpus. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; use vectorcraft_affinity::{Archive, Limits, stream}; -#[path = "support/corpus.rs"] -mod corpus; -#[path = "support/features.rs"] -mod features; +use crate::corpus; +use crate::features; fn files(dir: &Path, out: &mut Vec) { for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { @@ -90,7 +87,7 @@ fn public_documents_map_to_the_model() { #[test] fn affinity3_feature_retention_matches_pinned_signatures() { let Some(dir) = corpus::pinned() else { return }; - let expected = include_str!("support/affinity3-signatures.txt"); + let expected = include_str!("../support/affinity3-signatures.txt"); let mut checked = std::collections::BTreeSet::new(); for line in expected.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) { let (name, signature) = line.split_once('\t').expect("signature row must have a tab"); @@ -108,7 +105,7 @@ fn affinity3_feature_retention_matches_pinned_signatures() { doc.saved_by ); } - let manifest = include_str!("../../../xtask/affinity-corpus.sha256"); + let manifest = include_str!("../../../../xtask/affinity-corpus.sha256"); let af_names: std::collections::BTreeSet<_> = manifest.lines().filter_map(|l| l.split_once(" ")).map(|(_, n)| n).filter(|n| n.ends_with(".af")).collect(); assert_eq!(checked, af_names, "every pinned .af file needs a reviewed structural baseline"); diff --git a/crates/affinity/tests/text.rs b/crates/affinity/tests/it/text.rs similarity index 98% rename from crates/affinity/tests/text.rs rename to crates/affinity/tests/it/text.rs index db984652b..35a621f81 100644 --- a/crates/affinity/tests/text.rs +++ b/crates/affinity/tests/it/text.rs @@ -1,14 +1,8 @@ //! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance. //! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3 //! mixed-run fixture. Broad complex typography still needs independent visual validation. -#![cfg(feature = "synth")] -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod support { - pub mod corpus; - pub mod text_fixture; -} -use support::text_fixture::*; +use crate::text_fixture::*; use vectorcraft_affinity::{ Document, Limits, model::{Align, Kind, Paragraph, Text}, @@ -179,7 +173,7 @@ fn stored_fallback_font_is_used_only_when_it_names_a_font() { #[test] fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() { - let Some(corpus) = support::corpus::pinned() else { return }; + let Some(corpus) = crate::corpus::pinned() else { return }; // Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser // source was read; assertions describe this crate's parsing of its published MIT documents. let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap(); diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 2890790da..80d302b66 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -77,10 +77,32 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) { walk(Arc::make_mut(c), mode, key); } } + // A mask's art is drawn too: a brush on its strokes would run this brush again. Its colours + // are the mask's values, so they stay. + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + strip_brushes(Arc::make_mut(&mut m.art)); + } } walk(n, mode, key); } +/// Strip the brushes from every stroke under `n`, its masks' art included. +fn strip_brushes(n: &mut Node) { + for it in &mut n.appearance.items { + if let AppearanceItem::Stroke(s) = it { + s.brush = None; + } + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + strip_brushes(Arc::make_mut(c)); + } + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + strip_brushes(Arc::make_mut(&mut m.art)); + } +} + /// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer. pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { let amount = amount.clamp(0.0, 1.0); diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 24f4e7f5a..1f7af6e60 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -689,6 +689,10 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec, known: &mut Known) { /// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed. pub fn node_pieces(doc: &Document, n: &Node) -> Option> { let bp = node_bezpath(n)?; + // The library is parsed for a stroke that names a brush, not for every path. + if !n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some())) { + return None; + } let lib = library(doc); let mut out = vec![]; let mut any = false; diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index e02bcf48d..85ccbd990 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -156,6 +156,9 @@ impl Track { } } +/// The most flattened elements of a path kept. +const MAX_FLATTENED: usize = 4_000_000; + /// Flatten `bp` into tracks (one per subpath) with `tol` accuracy. pub fn tracks(bp: &BezPath, tol: f64) -> Vec { let mut out = vec![]; @@ -165,8 +168,20 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec { out.push(t); } }; + // Far outside any drawing a curve flattens to millions of points; past a few million, the + // rest are not kept. + let b = bp.control_box(); + let extent = b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()); + // NaN coordinates must be rejected too, so keep the negation of the `<=` check. + if extent.is_nan() || extent > 1e9 { + return out; + } let mut els = vec![]; - kurbo::flatten(bp.iter(), tol.max(1e-4), |el| els.push(el)); + kurbo::flatten(bp.iter(), tol.max(1e-4), |el| { + if els.len() < MAX_FLATTENED { + els.push(el); + } + }); for el in els { match el { PathEl::MoveTo(p) => { diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index c65862e67..1e43c45dc 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -495,7 +495,14 @@ impl<'d, 'a> Build<'d, 'a> { let path = PathData::from_bezpath(&bp); let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false }; let mut n = if path.subpaths.len() > 1 { - // Several loops (a hatch with islands) paint as one compound path. + // Several loops (a hatch with islands) paint as one compound path. Each loop is an + // object of its own, so it counts against the node limit like any other (`item` + // counts the compound path itself). + if self.nodes.saturating_add(path.subpaths.len()) >= MAX_NODES { + self.warn(TOO_MANY); + return None; + } + self.nodes += path.subpaths.len(); let children = path .subpaths .into_iter() diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs index a2d1dc11d..8733c96c8 100644 --- a/crates/cad/src/import/reader.rs +++ b/crates/cad/src/import/reader.rs @@ -421,21 +421,34 @@ fn font_family(g: &[Pair<'_>]) -> String { /// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout /// objects but with paper-space art has one paper layout. +/// The most bytes of layout and block names the layouts keep together. Each layout keeps its own +/// copy of its block's name, so many layouts naming one long block name multiplied it; real +/// drawings have a few dozen layouts with short names. +const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20; + fn layouts(objects: &[Record<'_>], block_records: &HashMap, entities: &[Record<'_>]) -> Vec { let mut paper: Vec<(i64, LayoutDef)> = vec![]; let mut seen = std::collections::HashSet::new(); + let mut name_bytes = 0usize; for r in objects.iter().filter(|r| r.kind == "LAYOUT") { // The layout's own groups follow its subclass marker. let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]); let Some(name) = own.name(1) else { continue }; - let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())).cloned(); - if name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) { + let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())); + if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) { continue; } let Some(block) = block else { continue }; - if seen.insert(name.to_ascii_lowercase()) { - paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) })); + let key = name.to_ascii_lowercase(); + let cost = name.len() + block.len(); + // A layout past the allowance is skipped alone: a later short one is still offered, and a + // skipped one reserves neither its name nor its bytes. + if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES { + continue; } + seen.insert(key); + name_bytes += cost; + paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) })); } paper.sort_by_key(|(order, _)| *order); if paper.is_empty() && groups(entities).any(in_paper_space) { diff --git a/crates/cad/src/import/text.rs b/crates/cad/src/import/text.rs index caf5f9bea..137d9cc31 100644 --- a/crates/cad/src/import/text.rs +++ b/crates/cad/src/import/text.rs @@ -4,13 +4,13 @@ /// A single-line text value (TEXT, ATTRIB) as plain text. pub(crate) fn plain(s: &str) -> String { - percent_codes(&escapes(s)) + percent_codes(&escapes(s, false)) } /// A multiline text value (MTEXT) as plain text with `\n` between paragraphs, and the font family /// its first font change names. pub(crate) fn mtext(s: &str) -> (String, Option) { - let s = escapes(s); + let s = escapes(s, true); let mut out = String::with_capacity(s.len()); let mut family = None; let mut chars = s.chars().peekable(); @@ -25,7 +25,7 @@ pub(crate) fn mtext(s: &str) -> (String, Option) { Some('S') => { // A stacked fraction `\Snum^den;` (or / or #): written as num/den. let arg = until_semicolon(&mut chars); - out.push_str(&arg.replace(['^', '#'], "/")); + out.push_str(&arg.replace("^ ", "^").replace(['^', '#'], "/")); } Some(f @ ('f' | 'F')) => { let arg = until_semicolon(&mut chars); @@ -63,20 +63,38 @@ fn until_semicolon(chars: &mut std::iter::Peekable>) -> Stri } /// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page) -/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. -fn escapes(s: &str) -> String { +/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. In +/// multiline text (`mtext`) `\\` is an escaped backslash and is kept as is, so that a `U+` after +/// it stays literal, and the `^` in a stacked fraction `\Snum^den;` is left for the caller. +fn escapes(s: &str, mtext: bool) -> String { let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(c) = rest.chars().next() { let after = rest.get(c.len_utf8()..).unwrap_or(""); + if mtext && c == '\\' && after.starts_with('\\') { + out.push_str("\\\\"); + rest = after.get(1..).unwrap_or(""); + continue; + } if c == '\\' && let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4)) + && hex.bytes().all(|b| b.is_ascii_hexdigit()) && let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) { out.push(ch); rest = after.get(6..).unwrap_or(""); continue; } + if mtext + && c == '\\' + && let Some(tail) = after.strip_prefix('S') + { + let end = tail.find(';').map_or(tail.len(), |i| i + 1); + out.push_str("\\S"); + out.push_str(tail.get(..end).unwrap_or("")); + rest = tail.get(end..).unwrap_or(""); + continue; + } if c == '^' && let Some(next) = after.chars().next() { @@ -135,6 +153,14 @@ fn percent_codes(s: &str) -> String { mod tests { use super::*; + #[test] + fn unicode_escape_requires_four_hex_digits() { + assert_eq!(plain("\\U++041"), "\\U++041"); + assert_eq!(plain("\\U+00G1"), "\\U+00G1"); + assert_eq!(plain("\\U+ 041"), "\\U+ 041"); + assert_eq!(plain("\\U+00C9"), "É"); + } + #[test] fn control_codes_and_escapes() { assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under"); @@ -152,6 +178,10 @@ mod tests { // Carets are written "^ " (a caret before another character is a control character). assert_eq!(mtext("x\\S3^ 4;").0, "x3/4"); assert_eq!(mtext("x\\S3#4;").0, "x3/4"); + assert_eq!(mtext("\\S1^2;").0, "1/2"); + assert_eq!(mtext("\\S+1^-2;^I").0, "+1/-2\t"); assert_eq!(mtext("trailing\\").0, "trailing\\"); + assert_eq!(mtext("a\\\\U+0041b").0, "a\\U+0041b", "an escaped backslash is not a Unicode escape"); + assert_eq!(mtext("a\\U+0041b").0, "aAb"); } } diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index ab9137050..1e7baac26 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -909,8 +909,13 @@ impl<'a> Scene<'a> { let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); - let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| m.text.as_str()); - let Some(s) = marker.or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; + // A right-to-left paragraph's marker is drawn with its tab first and its number's + // period at the number's left; the TEXT entity holds the characters in that order. + let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') { + Some(number) if m.tab == m.glyphs.start => format!(".{number}"), + _ => m.text.clone(), + }); + let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue }; let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill }; let Some(rgb) = self.paint_rgb(paint) else { continue }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 92f701d47..46b09f5ff 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -492,6 +492,30 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() { assert!((all(&e[1], 10)[0] - bullet_x).abs() < 1e-6, "the bullet where it is drawn: {} and {bullet_x}", all(&e[1], 10)[0]); } +/// A numbered list paragraph's number and period are a TEXT entity of their own where they are +/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the +/// number's left. +#[test] +fn a_number_is_text_of_its_own_before_its_paragraph() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let numbered = |text: &str, count: u32| { + let mut d = text_doc(text, 20.0); + let id = d.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(ParaList::numbered(NumberStyle::Arabic)); + t.para.left_indent = 30.0; + t.para.first_line_indent = -30.0; + t.list_count = count; + } + let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::>() + }; + assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]); + let rtl = numbered("שלום", 11); + assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::>(), ["שלום", ".12"]); + assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}"); +} + /// Area type that Shrink Text to Fit scales down is written at the height it draws at. #[test] fn shrunk_area_type_is_text_at_the_size_it_draws_at() { diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 5d4f1c974..1ead944e2 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -95,6 +95,9 @@ impl IccProfile { } pub fn from_bytes(name: Option, bytes: &[u8]) -> Result { + if !localized_text_fits(bytes) { + return Err(CmsError::BadProfile(format!("its localized names take more than {} KB", MAX_LOCALIZED_TEXT >> 10))); + } let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?; Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? }) } @@ -393,3 +396,36 @@ pub(super) fn gray_profile(name: &str) -> ColorProfile { p.gray_trc = ColorProfile::new_srgb().red_trc; labelled(p, name) } + +/// Most bytes of UTF-16 the localized names (`mluc` tags) of a profile may take together. +const MAX_LOCALIZED_TEXT: usize = 1 << 20; + +/// Whether the localized-name records of the `mluc` tags in ICC `bytes` take at most +/// [`MAX_LOCALIZED_TEXT`] together. moxcms copies each record into a string of its own, and records +/// may all point at the same bytes, so a profile under a megabyte could make it allocate +/// gigabytes. A table it can't read is left to moxcms to refuse. +pub(crate) fn localized_text_fits(bytes: &[u8]) -> bool { + let be32 = |b: &[u8], at: usize| b.get(at..at.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize); + let Some(tags) = be32(bytes, 128) else { return true }; + let mut total = 0usize; + for i in 0..tags { + let Some(entry) = i.checked_mul(12).and_then(|e| e.checked_add(132)) else { return false }; + let (Some(offset), Some(size)) = (be32(bytes, entry + 4), be32(bytes, entry + 8)) else { break }; + let Some(tag) = offset.checked_add(size).and_then(|end| bytes.get(offset..end)) else { continue }; + if !tag.starts_with(b"mluc") { + continue; + } + let records = be32(tag, 8).unwrap_or(0); + for r in 0..records { + // Each record's header: language, country, length, offset. + let Some(length) = r.checked_mul(12).and_then(|h| be32(tag, h + 16 + 4)) else { break }; + // Each record counts its 12-byte header too: records of length 0 are not free, so + // many tags pointing at one `mluc` can't make this loop quadratic. + total = total.saturating_add(length).saturating_add(12); + if total > MAX_LOCALIZED_TEXT { + return false; + } + } + } + true +} diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index ae3697fe3..fa2449bc0 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -152,3 +152,31 @@ fn cmyk_profiles_have_the_tags_of_an_output_profile() { assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}"); } } + +/// A profile whose localized-name records all point at one long string is refused before moxcms +/// copies each of them; a few short names pass. +#[test] +fn aliased_localized_names_are_refused() { + let profile = |records: u32, length: u32| { + let mut b = vec![0u8; 128]; + b.extend(1u32.to_be_bytes()); + let tag_at = 128 + 4 + 12; + let size = 16 + 12 * records + length; + b.extend(b"desc"); + b.extend((tag_at as u32).to_be_bytes()); + b.extend(size.to_be_bytes()); + b.extend(b"mluc"); + b.extend([0; 4]); + b.extend(records.to_be_bytes()); + b.extend(12u32.to_be_bytes()); + for _ in 0..records { + b.extend(b"enUS"); + b.extend(length.to_be_bytes()); + b.extend((16 + 12 * records).to_be_bytes()); + } + b.extend(vec![0u8; length as usize]); + b + }; + assert!(super::icc::localized_text_fits(&profile(3, 64))); + assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10))); +} diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 751e23896..798367ba4 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -162,8 +162,9 @@ impl Freeform { /// ([`Color::lerp_keep_model`]). pub fn recolor(&mut self, g: &Gradient) { let n = self.points.len(); - for (k, p) in self.points.iter_mut().enumerate() { - (p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model); + let at = (0..n).map(|k| k as f32 / (n - 1).max(1) as f32); + for (p, (color, opacity)) in self.points.iter_mut().zip(g.sample_increasing_with(at, Color::lerp_keep_model)) { + (p.color, p.opacity) = (color, opacity); } } diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index e97ca634a..f23eb18e6 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -133,6 +133,16 @@ impl GradientStop { } } +/// The colour and opacity at `t` between stops `a` and `b`, through `a`'s midpoint (`u` = midpoint +/// maps to `v` = ½), mixed by `mix`. +fn between(a: &GradientStop, b: &GradientStop, t: f32, mix: &impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) { + let span = (b.offset - a.offset).max(1e-6); + let u = (t - a.offset) / span; + let m = a.midpoint.clamp(0.01, 0.99); + let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) }; + (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v) +} + fn one() -> f32 { 1.0 } @@ -191,12 +201,7 @@ impl Gradient { for w in stops.windows(2) { let (a, b) = (&w[0], &w[1]); if t <= b.offset { - let span = (b.offset - a.offset).max(1e-6); - let u = (t - a.offset) / span; - // Map through the midpoint: u=mid → 0.5. - let m = a.midpoint.clamp(0.01, 0.99); - let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) }; - return (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v); + return between(a, b, t, &mix); } } match stops.last() { @@ -204,6 +209,33 @@ impl Gradient { None => (Color::BLACK, 1.0), } } + /// [`Self::sample_with`] at each of `ts`, which must not decrease. Each search starts where + /// the one before it stopped, so the work is the samples plus the stops, not their product. + pub fn sample_increasing_with(&self, ts: impl IntoIterator, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> { + let stops = &self.stops; + // The shared index assumes ordered stops; others (a file's, unsorted) sample one by one. + if stops.windows(2).any(|w| w[1].offset < w[0].offset) { + return ts.into_iter().map(|t| self.sample_with(t, &mix)).collect(); + } + let mut j = 0; + ts.into_iter() + .map(|t| { + let Some(first) = stops.first() else { return (Color::BLACK, 1.0) }; + if t <= first.offset { + return (first.color, first.opacity); + } + // The first pair whose end `t` doesn't pass, as `sample_with` finds it: one that a + // smaller `t` passed, this one passes too. + while j + 1 < stops.len() && t > stops[j + 1].offset { + j += 1; + } + match (stops.get(j), stops.get(j + 1)) { + (Some(a), Some(b)) => between(a, b, t, &mix), + _ => stops.last().map_or((Color::BLACK, 1.0), |l| (l.color, l.opacity)), + } + }) + .collect() + } /// Stops expanded so that midpoints are represented as explicit stops (for renderers without /// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that /// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it). @@ -213,13 +245,18 @@ impl Gradient { /// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None` /// on the gradient's own stops and a Perceptual gradient's other in-between stops). pub fn expanded(&self) -> impl Iterator)> + '_ { + // Direct interpolation matches `sample` only for strictly increasing offsets. + let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset); self.stops.iter().enumerate().flat_map(move |(i, s)| { let next = self.stops.get(i + 1); let off_centre = (s.midpoint - 0.5).abs() > 1e-3; // Classic: a stop at the midpoint, when it is off-centre. let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| { let t = s.offset + (n.offset - s.offset) * s.midpoint; - let (c, o) = self.sample(t); + // Mixed from the two stops it lies between, as `sample` would: searching all the + // stops again for each midpoint made many stops cost their square. + let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v); + let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) }; (t, c, o, Some(s.midpoint)) }); // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the @@ -776,6 +813,26 @@ mod tests { 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(); diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 80f740321..b1f51213b 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -515,13 +515,24 @@ fn read_acb(bytes: &[u8], fallback: &str) -> Result { let spot = data.0.windows(8).last() != Some(b"spflproc".as_slice()); let mut lib = SwatchLibrary { name: if title.is_empty() { fallback.into() } else { title }, ..Default::default() }; let mut names = UniqueNames::default(); + // Every name gets the prefix and the suffix, and is kept twice (the swatch's and the set of + // names taken): a megabyte book of long affixes and many colors came to hundreds of MB. + let mut kept = 0usize; for (name, color) in colors.into_iter().filter(|(n, _)| !n.trim().is_empty()) { + kept = kept.saturating_add(2 * (prefix.len() + name.len() + suffix.len())); + if kept > ACB_MAX_NAME_BYTES { + return Err(format!("the color book's names take more than {} MB", ACB_MAX_NAME_BYTES >> 20)); + } let name = names.unique(format!("{prefix}{name}{suffix}").trim().to_string()); lib.swatches.push(Swatch { name, paint: Paint::solid(color), global: spot, spot }); } Ok(lib) } +/// Most bytes a color book's full color names (with its prefix and suffix) may take, counted +/// twice as they are kept twice. +const ACB_MAX_NAME_BYTES: usize = 64 << 20; + /// The swatch of an `.ase` color block, `None` for a model other than RGB, CMYK, Lab and Gray. /// Components are clamped to their ranges. fn ase_swatch(body: &mut AseData) -> Result, String> { diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index b336ff152..9895cc7f9 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -314,15 +314,15 @@ impl PressureProfile { 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 { - p.windows(2) - .find_map(|w| match w { - [a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)), - _ => None, - }) - .unwrap_or(last.1) + 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 } } diff --git a/crates/doc/src/clipnest.rs b/crates/doc/src/clipnest.rs index f77f9e018..7d94e3b58 100644 --- a/crates/doc/src/clipnest.rs +++ b/crates/doc/src/clipnest.rs @@ -40,11 +40,18 @@ pub type Drawn = Vec<(Vec>, Node)>; /// The nodes of `items`, those under clips grouped into clipping groups and those in groups into /// groups (ids from `doc`), in order. pub fn nest(doc: &mut Document, items: Drawn) -> Vec> { - nest_at(doc, items, 0) + let mut left = MAX_CLIP_ELEMENTS; + nest_at(doc, items, 0, &mut left) } -/// [`nest`] for the clips at `depth` and deeper. -fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { +/// Most path elements the clipping paths [`nest`] makes may have together. Each run of objects +/// under a clip gets its own copy of the clip's path, so one large clip used again around many +/// short runs (an EMF restoring one saved clip between drawings) multiplied it. Past it, runs are +/// grouped without their clip. +pub const MAX_CLIP_ELEMENTS: usize = 8_000_000; + +/// [`nest`] for the clips at `depth` and deeper, with `left` of [`MAX_CLIP_ELEMENTS`]. +fn nest_at(doc: &mut Document, items: Drawn, depth: usize, left: &mut usize) -> Vec> { let mut out: Vec> = vec![]; let mut group: Drawn = vec![]; let mut clip: Option> = None; @@ -52,7 +59,7 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { let here = chain.get(depth).cloned(); if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) { if let Some(c) = clip.take() { - out.push(clip_group(doc, &c, std::mem::take(&mut group), depth)); + out.push(clip_group(doc, &c, std::mem::take(&mut group), depth, left)); } clip = here.clone(); } @@ -62,16 +69,23 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { } } if let Some(c) = clip { - out.push(clip_group(doc, &c, group, depth)); + out.push(clip_group(doc, &c, group, depth, left)); } out } /// A clipping group of `items`, clipped by `c` (their clip at `depth`), or their group when `c` is /// a plain group. -fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc { +fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize, left: &mut usize) -> Arc { let mut children = vec![]; - if let Some((bp, r)) = &c.region { + let region = c.region.as_ref().filter(|(bp, _)| match left.checked_sub(bp.elements().len()) { + Some(rest) => { + *left = rest; + true + } + None => false, + }); + if let Some((bp, r)) = region { let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(bp), Appearance::default()); if let NodeKind::Path { clipping, rule, .. } = &mut path.kind { *clipping = true; @@ -79,6 +93,6 @@ fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc GraphDesign::MAX_NODES { + if kept.iter().any(|k| k.name == name) || d.art.count_with_masks() > GraphDesign::MAX_NODES { continue; } kept.push(GraphDesign { name, art: std::sync::Arc::new(GraphDesign::plain_art(&d.art)) }); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4e42295f1..a222e8e91 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -93,7 +93,7 @@ pub use appearance::{ }; pub use assets::ExportAsset; pub use corners::LiveCorners; -pub use depth::MAX_DEPTH; +pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES}; pub use graph::{ ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide, @@ -959,6 +959,12 @@ pub struct Document { /// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved. #[serde(skip)] pub new_art: NewArtIds, + /// The highest artboard id given out ([`Document::next_artboard_id`]) or deleted from the + /// document ([`Document::retire_artboard_id`]): a new artboard takes a higher one. Shared by + /// every copy of the document, the undo history's included, so an undo does not lower it. + /// Never saved. + #[serde(skip)] + pub retired_artboard_id: RetiredArtboardId, /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this /// is set, so documents without any pay nothing for them. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -968,6 +974,27 @@ 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 } @@ -1052,6 +1079,7 @@ impl Document { puppet, new_art, dimensions, + retired_artboard_id: _, index: _, } = self; *version == other.version @@ -1225,6 +1253,7 @@ impl Document { puppet: None, new_art: Default::default(), dimensions: false, + retired_artboard_id: Default::default(), index: Default::default(), }; let id = d.alloc_id(); @@ -1940,8 +1969,18 @@ impl Document { 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 { - free_id(self.artboards.iter().map(|a| a.id)) + 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) @@ -1964,6 +2003,15 @@ impl Document { { 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); diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 8632578c2..28e0a659c 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -86,13 +86,25 @@ pub fn hash_bytes(bytes: &[u8]) -> String { } /// [`hash_bytes`] of everything `r` reads, read 64 KB at a time. -pub fn hash_reader(mut r: impl std::io::Read) -> std::io::Result { +pub fn hash_reader(r: impl std::io::Read) -> std::io::Result { + hash_reader_within(r, u64::MAX).map(|(hash, _)| hash) +} + +/// [`hash_reader`] of at most `max` bytes, and how many it read: `max` + 1 when there were more (the +/// hash then covers only the first `max` + 1). A file that grew since its size was looked at isn't +/// read to its new end. +pub fn hash_reader_within(r: impl std::io::Read, max: u64) -> std::io::Result<(String, u64)> { + use std::io::Read; + let mut r = r.take(max.saturating_add(1)); let mut buf = vec![0; 1 << 16]; - let mut h = FNV_BASIS; + let (mut h, mut read) = (FNV_BASIS, 0u64); loop { match r.read(&mut buf) { - Ok(0) => return Ok(format!("img{h:016x}")), - Ok(n) => h = fnv1a(h, buf.get(..n).unwrap_or_default()), + Ok(0) => return Ok((format!("img{h:016x}"), read)), + Ok(n) => { + h = fnv1a(h, buf.get(..n).unwrap_or_default()); + read = read.saturating_add(n as u64); + } Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} Err(e) => return Err(e), } @@ -405,6 +417,9 @@ mod tests { let bytes: Vec = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect(); assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes)); assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string())); + // Within a size: the whole file at that size; one byte past it when it is longer. + assert_eq!(hash_reader_within(&bytes[..], bytes.len() as u64).unwrap(), (hash_bytes(&bytes), bytes.len() as u64)); + assert_eq!(hash_reader_within(&bytes[..], 3).unwrap().1, 4); } #[test] diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index b434245cc..f84d0f865 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1614,6 +1614,14 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { /// 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); @@ -1791,6 +1799,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { /// [`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), @@ -1809,6 +1818,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node { /// [`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(); @@ -1832,9 +1842,21 @@ pub fn expand_deep(n: &Node, outline: Outliner) -> Node { expand_deep_hooks(n, outline.into()) } -/// [`expand_deep`] with [`Hooks`]. +/// [`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 mut out = if is_live(n) { expanded_group_hooks(n, hooks) } else { n.clone() }; + 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) { @@ -1845,6 +1867,48 @@ pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { 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; diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 1a84ef219..151365c5d 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -105,7 +105,13 @@ fn pinned_or(get: impl Fn(&str) -> Option, clock: impl Fn() -> Option Option) -> Option { - get("SOURCE_DATE_EPOCH")?.trim().parse::().ok().filter(|t| *t >= 0) + let value = get("SOURCE_DATE_EPOCH")?; + let value = value.trim(); + // `i64::from_str` also takes a leading `+`, which `date +%s` never prints. + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + value.parse::().ok() } /// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build). @@ -193,7 +199,7 @@ mod tests { assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980)); assert_eq!(stamp_from_env(env(Some("0"))), Some(0)); assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980)); - for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] { + for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "+1791208980", "+0", "99999999999999999999"] { assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}"); } assert_eq!(stamp_from_env(env(None)), None, "unset"); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index f9a6a11fd..9eebb57f2 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -1452,6 +1452,18 @@ impl Node { 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) { diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index a94edf42a..9b00da57e 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -525,6 +525,9 @@ fn rename(n: &mut Node, prefix: &str) { }); } +/// Most placed documents [`Document::with_placed_art`] reads for one export, nested ones included. +pub const MAX_PLACED_READS: usize = 256; + impl Document { /// This document ready to export its placed documents: their files read (the file read again /// when only the preview is stored, else the preview), and the resources their art uses added @@ -544,9 +547,17 @@ impl Document { } let mut d = self.clone(); let mut found: Vec> = vec![]; + // Placed documents are told apart by the names their art's resources get here, so one + // file placed in itself, or many times in a file placed many times, read again in every + // branch: past [`MAX_PLACED_READS`] reads, the rest show their previews. + let mut reads = 0usize; while let Some((p, depth)) = queue.pop() { let Some(blob) = d.images.get(&p.key).cloned() else { continue }; - let full = if depth < MAX_DEPTH { full_bytes(&p, &blob) } else { None }; + // Top-level placements are distinct already (by key): only reads nested inside placed + // documents count, so a catalogue placing many files still reads each of them. + let read_full = depth < MAX_DEPTH && (depth == 0 || reads < MAX_PLACED_READS); + reads += usize::from(read_full && depth > 0); + let full = if read_full { full_bytes(&p, &blob) } else { None }; if full.and_then(|b| read(&p, &b)).is_none() { let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; if let Some(bytes) = preview { @@ -562,7 +573,7 @@ impl Document { .chain(e.doc.symbols.iter().map(|s| &s.art)) .chain(e.doc.graph_designs.iter().map(|g| &g.art)) .chain(e.doc.patterns.iter().flat_map(|p| &p.art)); - for root in inner { + for root in inner.filter(|_| reads < MAX_PLACED_READS) { walk_all(root, &mut |n| { if let NodeKind::PlacedDocument(q) = &n.kind { let key = format!("{prefix}{}", q.key); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 275eea4bb..6ce257e78 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -508,9 +508,102 @@ impl Bullet { } } -/// The list a paragraph is in (Paragraph panel › Bullets). 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 +/// 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 @@ -518,19 +611,52 @@ impl Bullet { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(default)] pub struct ParaList { - /// The bullet each paragraph of the list starts with. + /// 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 } + Self { bullet, number: None } } - /// The characters of the marker the layout draws before the paragraph. - pub fn marker(&self) -> String { - self.bullet.character().to_string() + /// 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) @@ -1501,6 +1627,12 @@ pub struct TextObject { /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub paras: Vec, + /// Threaded text: the first paragraph continues from the frame before this one in its thread, + /// so its first line here has no first-line indent and no list marker. The engine sets it for + /// every frame of a thread when the story flows through them and when a file opens, and clears + /// it on a frame that leaves its thread and on a copy. Not saved. + #[serde(skip)] + pub first_para_continues: bool, /// Area Type Options (area type only). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub area: AreaOptions, @@ -1536,6 +1668,12 @@ pub struct TextObject { /// #[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")] pub fractional_widths: bool, + /// Threaded text: the numbered list paragraphs the story's earlier frames count before this + /// frame's first paragraph ([`ParaList::next_marker`]), so one count runs through the story. + /// The engine sets it whenever it re-flows a thread. Type that is not threaded has 0, and the + /// file saves the count only when it is not 0. + #[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")] + pub list_count: u32, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, @@ -1627,6 +1765,7 @@ impl TextObject { ruby: Vec::new(), para: ParaStyle::default(), paras: Vec::new(), + first_para_continues: false, area: AreaOptions::default(), path_effect: PathEffect::default(), path_align: PathAlign::default(), @@ -1635,6 +1774,7 @@ impl TextObject { optical_margin: false, system_layout: false, fractional_widths: true, + list_count: 0, cached_bounds: None, cached_baselines: Vec::new(), } @@ -2132,6 +2272,47 @@ mod tests { assert_eq!(same, d); } + /// Numbered lists: each number style writes its numbers and rolls over (letters after Z, + /// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds + /// a period, and the count runs through list paragraphs, skips empty and bulleted ones and + /// starts again after a paragraph in no list. A list from before number styles reads as + /// bulleted, and a bulleted list saves no number style. + #[test] + fn number_styles_write_their_numbers_and_roll_over() { + let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::>(); + assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]); + assert_eq!( + numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]), + ["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"] + ); + assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]); + assert_eq!( + numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]), + ["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"] + ); + assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]); + assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]); + assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]); + assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV."); + assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}"); + let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot))); + let mut count = 0; + let markers: Vec> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)] + .iter() + .map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count)) + .collect(); + assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]); + assert_eq!(count, 1); + let read = |v: serde_json::Value| serde_json::from_value::(v).unwrap(); + assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash)); + assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits)); + assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"})); + assert_eq!( + serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(), + serde_json::json!({"bullet": "dot", "number": "lowerRoman"}) + ); + } + #[test] fn character_controls_reject_collapsed_and_nonfinite_transforms() { let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); diff --git a/crates/doc/src/xml_id.rs b/crates/doc/src/xml_id.rs index ba454de01..08dd5edbe 100644 --- a/crates/doc/src/xml_id.rs +++ b/crates/doc/src/xml_id.rs @@ -32,9 +32,11 @@ pub fn to_object_name(id: &str) -> String { pub fn from_object_name(name: &str) -> String { let mut out = String::with_capacity(name.len().saturating_mul(2)); let last = name.chars().count().saturating_sub(1); - for (i, c) in name.chars().enumerate() { - if c == ' ' && i == last { - // A trailing bare `_` could read back as the end of a uniqueness suffix. + let chars: Vec = name.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == ' ' && (i == last || starts_fake_escape(chars.get(i + 1..).unwrap_or_default())) { + // A trailing bare `_` could read back as the end of a uniqueness suffix, and a bare + // `_` before `x_` would read back as an `_xHH_` escape. out.push_str("_x20_"); } else if c == ' ' { out.push('_'); @@ -68,6 +70,19 @@ pub fn is_valid(id: &str) -> bool { chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':')) } +/// Whether the encoded `rest` (after a space) starts with `x`, 1 to 6 hex digits and a character +/// whose encoding begins with `_`, so that a bare `_` before it would form an escape. +fn starts_fake_escape(rest: &[char]) -> bool { + let Some(('x', digits)) = rest.split_first() else { + return false; + }; + let n = digits.iter().take_while(|c| c.is_ascii_hexdigit()).count(); + if n == 0 || n > 6 { + return false; + } + digits.get(n).is_some_and(|&c| c == ' ' || c == '_' || !(c.is_alphanumeric() || matches!(c, '.' | '-' | ':'))) +} + /// `_xHHHH_` at the start of `s`: the character and the escape's length. fn escaped(s: &str) -> Option<(char, usize)> { let body = s.strip_prefix("_x")?; @@ -119,6 +134,15 @@ mod tests { assert_eq!(to_object_name("a_x-41_b"), "a x-41 b"); } + #[test] + fn space_before_x_hex_and_underscore_is_not_an_escape() { + for name in ["layer x1 copy", "a xa b", "a xe b", "a xb b", "a x1(b", "a x1_b", "a x123456 c"] { + let id = from_object_name(name); + assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}"); + } + assert_eq!(from_object_name("layer x1 copy"), "layer_x20_x1_copy"); + } + #[test] fn round_trip_names_that_cannot_start_an_xml_name() { for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] { diff --git a/crates/effects/src/pixel/loaded.rs b/crates/effects/src/pixel/loaded.rs index f08d2ef1c..bb885b239 100644 --- a/crates/effects/src/pixel/loaded.rs +++ b/crates/effects/src/pixel/loaded.rs @@ -7,6 +7,7 @@ //! directions. use std::io::Cursor; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use vectorcraft_geom::Point; @@ -26,6 +27,15 @@ const RELIEF: f64 = 1.0; /// How many decoded textures [`texture`] keeps. const KEEP: usize = 4; +/// Most bytes of gray levels the loaded textures alive at once may hold, wherever they are kept +/// (the effects of every drawing hold theirs): a texture that would pass it isn't decoded. Each +/// one is decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`], but a document's many Glass +/// effects each kept their own. +const MAX_LIVE: usize = 512 << 20; + +/// Bytes of gray levels the [`LoadedTexture`]s alive hold ([`MAX_LIVE`]). +static LIVE: AtomicUsize = AtomicUsize::new(0); + /// A loaded texture: `width` × `height` gray levels, row by row from the top. #[derive(Debug, PartialEq, Eq)] pub struct LoadedTexture { @@ -34,7 +44,19 @@ pub struct LoadedTexture { gray: Vec, } +impl Drop for LoadedTexture { + fn drop(&mut self) { + LIVE.fetch_sub(self.gray.len(), Ordering::Relaxed); + } +} + impl LoadedTexture { + /// A texture of `gray`, counted in [`LIVE`]. + fn new(width: u32, height: u32, gray: Vec) -> Self { + LIVE.fetch_add(gray.len(), Ordering::Relaxed); + Self { width, height, gray } + } + /// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's /// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray). /// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels. @@ -52,7 +74,7 @@ impl LoadedTexture { ((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8 }) .collect(); - Some(Self { width, height, gray }) + Some(Self::new(width, height, gray)) } /// The size in pixels. @@ -71,14 +93,23 @@ impl LoadedTexture { /// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`]; /// `None` when `png` isn't a PNG or holds no pixels. + /// `None` too when its gray levels would take the live textures past [`MAX_LIVE`]. fn from_png(png: &[u8]) -> Option { - let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png); - let mut limits = image::Limits::default(); - limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC); - reader.limits(limits); - let img = reader.decode().ok()?.to_luma8(); + let reader = || { + let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC); + reader.limits(limits); + reader + }; + let (w, h) = reader().into_dimensions().ok()?; + let gray = (w as usize).checked_mul(h as usize)?; + if LIVE.load(Ordering::Relaxed).saturating_add(gray) > MAX_LIVE { + return None; + } + let img = reader().decode().ok()?.to_luma8(); let (width, height) = img.dimensions(); - (width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() }) + (width > 0 && height > 0).then(|| Self::new(width, height, img.into_raw())) } /// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray @@ -99,24 +130,26 @@ impl LoadedTexture { } } -/// The textures [`texture`] decoded last, with their stored form, the newest last. -static DECODED: Mutex)>> = Mutex::new(Vec::new()); +/// The textures [`texture`] decoded last, with their PNG, the newest last. +static DECODED: Mutex, Arc)>> = Mutex::new(Vec::new()); /// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last -/// [`KEEP`] textures decoded are kept for the next call. +/// [`KEEP`] textures decoded are kept for the next call, found by their PNG: values that differ +/// only in how the base64 is spaced share one. pub fn texture(stored: &str) -> Option> { + let png = base64_decode(stored)?; let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(i) = decoded.iter().position(|(s, _)| s == stored) { + if let Some(i) = decoded.iter().position(|(p, _)| *p == png) { let entry = decoded.remove(i); let t = entry.1.clone(); decoded.push(entry); return Some(t); } - let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?); + let t = Arc::new(LoadedTexture::from_png(&png)?); if decoded.len() >= KEEP { decoded.remove(0); } - decoded.push((stored.to_string(), t.clone())); + decoded.push((png, t.clone())); Some(t) } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0deed993a..8cab92a69 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -153,7 +153,8 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::Bez let layout = vectorcraft_text::layout(db, t); let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; let mut all = kurbo::BezPath::new(); - for g in &layout.glyphs { + // Ruby (furigana) glyphs are painted with their base text's run, as the renderer paints them. + for g in layout.glyphs.iter().chain(&layout.ruby) { if let Some(r) = runs.get_mut(g.run) { r.extend(g.outline.iter()); } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 17507f3ce..b2528818b 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -12,6 +12,24 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; pub const REVOLVE: &str = "threeD.revolve"; +/// Most bytes the copies of the object's paint in the faces may take together. Each face and the +/// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of +/// thousands of faces took gigabytes; past this the source path is drawn. +const MAX_PAINT_COPIES: usize = 256 << 20; + +/// About the bytes one copy of `paint` takes. +fn paint_bytes(paint: &Paint) -> usize { + use std::mem::size_of; + use vectorcraft_doc::color::{FreeformPoint, GradientStop}; + let own = size_of::(); + let Paint::Gradient(g) = paint else { return own }; + let freeform = g + .freeform + .as_ref() + .map_or(0, |f| f.points.len() * size_of::() + f.lines.iter().map(|l| l.len() * size_of::()).sum::()); + own + size_of::() + g.gradient.stops.len() * size_of::() + freeform +} + fn visible(fx: &[Effect]) -> bool { fx.iter().any(|e| e.visible && e.id == REVOLVE) } @@ -93,6 +111,10 @@ fn surface( let options = revolve_options(&merged_params(REVOLVE, &fx.params)); let colored = options.light_color != [1.0; 3] || options.lights.is_some(); let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; + // A face's fill, its shadow and their seam strokes: up to four copies of the paint. + if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES { + return None; + } // Visibility is an expansion option, never a change to live geometry or export baking. // Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility // failure also retains the complete surface rather than losing any of the user's art. diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 44d24343b..87eca97d7 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -58,7 +58,9 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec, }; match seg { PathSeg::Line(l) => push(l.p1), - _ => kurbo::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| { + // Bounded per curve: a projection can blow a small curve up enough that kurbo sizes + // an enormous buffer from it. + _ => vectorcraft_geom::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| { if let PathEl::LineTo(p) = el { push(p); } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b83b99032..f495697ce 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -45,6 +45,11 @@ serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +# Whether a recovery area folder is this account's own (`FolderStore::own_area`). Already in +# Cargo.lock (tempfile, ui-egui), so this adds no new crate. +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["std", "process"] } + [dev-dependencies] vectorcraft-affinity = { workspace = true, features = ["synth"] } crc32fast = { workspace = true } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index a441d3a07..3f7da1629 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -19,8 +19,7 @@ use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; use vectorcraft_pathops as po; use vectorcraft_tools::builder::{ - self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline, - sorted_roots, + self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sorted_roots, }; use vectorcraft_trace as tr; @@ -58,7 +57,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -67,7 +66,7 @@ pub fn specs() -> Vec { "Merge", ["Object", "Live Paint"], None, - "{ids?|id?} add the other selected paths (and Live Paint groups) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}", + "{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}", has_multi_or_ids, lp_merge ), @@ -130,7 +129,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), @@ -296,7 +295,6 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { if map.shapes.is_empty() { return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); } - let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25); if let Some(tol) = split_tol && let Some(&q) = pts.first() && let Some((i, run)) = map.stroke_at(&outlines, q, tol) @@ -329,9 +327,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { 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() }; - for q in sample_polyline(&pts, step) { - touched.add(map.hit(q, piece_tol)); - } + touched.extend(&map.hits_along(&pts, piece_tol)); touched.edges = map.erased_edges(&touched); if touched.is_empty() { return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); @@ -358,7 +354,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { 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 merged (or deleted) regions. + // 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() { @@ -609,9 +605,24 @@ fn merged_gap_options(groups: &[GapOptions]) -> GapOptions { 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); } @@ -644,6 +655,7 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result .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) @@ -951,40 +963,44 @@ fn view_param(cmd: &str, p: &Value) -> Result> { v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids()))) } -fn trace_params(p: &Value) -> Result { +/// 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, "imageTrace.make")?; + 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("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?; + 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("imageTrace.make", "params must be an object")) }; + 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("imageTrace.make", e.to_string()))?; + 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("imageTrace.make", "Create needs fills, strokes or both")); + return Err(bad(c, "Create needs fills, strokes or both")); } if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) { - return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels")); + return Err(bad(c, "strokeWidth must be a positive number of pixels")); } Ok(params) } /// Document Library in Color mode: look the library's colours up into `params.swatches` → each -/// one as traced (sRGB) with the colour to fill with. -fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result> { - const C: &str = "imageTrace.make"; +/// one as traced (sRGB) with the colour to fill with. Errors name command `c`. +fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { return Ok(vec![]); } let key = params.library.as_str(); let colors = super::swatchlib::library_colors(s, key) - .ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?; + .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"))); + return Err(bad(c, format!("`{key}` has no colours"))); } let library: Vec<([u8; 3], Color)> = colors .into_iter() @@ -1088,9 +1104,9 @@ fn grouped(d: &mut Document, nodes: Vec, parents: &[Option]) -> Vec } fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { - const C: &str = "imageTrace.make"; - let mut params = trace_params(p)?; - let view = view_param(C, p)?; + 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". @@ -1114,14 +1130,14 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { _ => None, } }) - .ok_or_else(|| bad(C, "select an image (or an Image Trace group)"))? + .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(s, &mut params)?; + 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)); + 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; @@ -1197,18 +1213,21 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { /// 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 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)) -> Vec { +/// 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 size.0.max(size.1) > LARGE_TRACE_SIDE { + 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 u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(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, @@ -1294,11 +1313,19 @@ mod trace_limit_tests { #[test] fn trace_warnings_name_a_large_or_scaled_down_image() { - assert!(trace_warnings((1500, 1500), (1500, 1500)).is_empty()); - assert_eq!(trace_warnings((1501, 10), (1501, 10)).len(), 1); - let w = trace_warnings((16000, 12000), (9459, 7094)); + 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; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index b8a844f31..dd841c661 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -206,6 +206,8 @@ pub(crate) fn draw_inside_group(d: &mut vectorcraft_doc::Document, target: NodeI let gid = d.alloc_id(); d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![std::sync::Arc::new(mask)], clip: true }))?; d.move_node(target, Some(gid), usize::MAX)?; + // Copied type whose layout caches `reid` dropped has them again. + super::typecmd::refresh_bounds_of(d, &[gid]); Ok(gid) } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index c3407b6b9..0400ab123 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -436,8 +436,11 @@ fn snap_xf(b: Rect, half: bool) -> Affine { } fn pixel_perfect(s: &mut Session, p: &Value) -> Result { - let ids: Vec = - if p.get("ids").is_some() { super::edit::outermost(&s.doc()?.doc, targets(s, p)?) } else { super::edit::selected_roots(s)? }; + let ids: Vec = if p.get("ids").is_some() || p.get("id").is_some() { + super::edit::outermost(&s.doc()?.doc, targets(s, p)?) + } else { + super::edit::selected_roots(s)? + }; let n = ids.len(); s.edit("Make Pixel Perfect", |d, _| { for id in &ids { diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index cd521a58b..88da38361 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -269,11 +269,11 @@ fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result> { } fn finish_same(s: &mut Session, cmd: &str, ids: Vec) -> Result { - let n = ids.len(); let from = s.doc()?.selection.objects.clone(); s.select(|_, sel| sel.set(ids))?; - s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from)); - Ok(json!({ "count": n })) + let st = s.doc_mut()?; + st.last_selection_cmd = Some((cmd.to_string(), json!({}), from)); + Ok(json!({ "count": st.selection.objects.len() })) } fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result { @@ -312,9 +312,9 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool) fn by_kind(s: &mut Session, f: impl Fn(&Node) -> bool) -> Result { let ids = candidates(s, f)?; - let n = ids.len(); s.select(|_, sel| sel.set(ids))?; - Ok(json!({ "count": n })) + // The objects selected, after Lock Guides has left out guide paths. + Ok(json!({ "count": s.doc()?.selection.objects.len() })) } fn direction_handles(s: &mut Session, _: &Value) -> Result { @@ -617,7 +617,7 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result { if v { let active = s.active; for (i, st) in s.docs.iter_mut().enumerate() { - if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) { + if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) { st.revision += 1; } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1ac71598e..aadd456ef 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate), cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), - cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find), + cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find), cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), cmd!( "document.setUnits", @@ -99,8 +99,8 @@ pub fn specs() -> Vec { "Begin Undo Group", [], None, - "{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)", - has_doc, + "{label?: string} until edit.undoGroup.end, the edits made in the document active at the begin, and in each document opened or created before then (also when no document is open; the edits of a command that opens a document and edits it stay that command's undo step), are one undo step in that document, named `label` (else after its first edit); in a document that was open before the begin and is switched to (document.activate), each edit stays its own undo step: for automation, such as MCP command_batch with `atomic` (#1163)", + always, undo_group_begin ), cmd!( @@ -109,7 +109,7 @@ pub fn specs() -> Vec { [], None, "{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone", - has_doc, + always, undo_group_end ), cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut), @@ -243,19 +243,82 @@ fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result> { } fn doc_find(s: &mut Session, p: &Value) -> Result { + const C: &str = "document.find"; // A filter of the wrong type is an error, not dropped: dropping it would widen the search. let needle = |key: &str| match p.get(key) { None | Some(Value::Null) => Ok(None), Some(Value::String(v)) => Ok(Some(v.to_lowercase()).filter(|v| !v.is_empty())), - Some(_) => Err(bad("document.find", format!("`{key}` must be a string"))), + Some(_) => Err(bad(C, format!("`{key}` must be a string"))), }; - let filter = inspect::FindFilter { name: needle("name")?, kind: needle("kind")?, text: needle("text")? }; - if filter.is_empty() { - return Err(bad("document.find", "give at least one of `name`, `kind`, `text`")); + let rect = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match finite_numbers::<4>(v) { + Some([x, y, w, h]) if w >= 0.0 && h >= 0.0 => Ok(Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h))), + _ => Err(bad(C, format!("`{key}` must be [x, y, width, height]: four finite numbers, width and height not negative"))), + }, + }; + let bound = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).map(Some).ok_or_else(|| bad(C, format!("`{key}` must be a number not below 0"))), + }; + let range = |name: &str| -> Result { + let (min_key, max_key) = (format!("min{name}"), format!("max{name}")); + let r = inspect::FindRange { min: bound(&min_key)?, max: bound(&max_key)? }; + if let (Some(min), Some(max)) = (r.min, r.max) + && min > max + { + return Err(bad(C, format!("`{min_key}` is greater than `{max_key}`"))); + } + Ok(r) + }; + let anchors = range("Anchors")?; + if [anchors.min, anchors.max].iter().flatten().any(|n| n.fract() != 0.0) { + return Err(bad(C, "`minAnchors` and `maxAnchors` must be whole numbers")); } - let limit = opt_u64(p, "document.find", "limit")?.unwrap_or(100); - let (matches, total) = inspect::find_nodes(&s.doc()?.doc, &filter, limit); - Ok(json!({"matches": matches, "total": total})) + let filter = inspect::FindFilter { + name: needle("name")?, + kind: needle("kind")?, + text: needle("text")?, + within: rect("within")?, + intersects: rect("intersects")?, + width: range("Width")?, + height: range("Height")?, + thickness: range("Thickness")?, + area: range("Area")?, + anchors, + }; + if filter.is_empty() { + return Err(bad( + C, + "give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`", + )); + } + let select = match p.get("select") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad(C, "`select` must be true or false"))?, + }; + let limit = opt_u64(p, C, "limit")?.unwrap_or(100); + let (matches, ids) = inspect::find_nodes(&s.doc()?.doc, &filter, limit); + let mut reply = json!({"matches": matches, "total": ids.len()}); + if select { + reply["selected"] = select_found(s, p, &ids)?["count"].clone(); + s.journal_query(); + } + Ok(reply) +} + +/// Select the objects among `ids` that Select All and the objects inside them take (none hidden or +/// locked, nor inside a hidden or locked one), leaving out those inside another of them and the +/// members of compound paths, unless a compound path is isolated. Select > Reselect repeats +/// `document.find` with `p`. +fn select_found(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result { + let st = s.doc()?; + let compound = |n: &Node| matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }); + let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound))); + let found: BTreeSet = ids.iter().copied().collect(); + super::select::select_where(s, "document.find", p, |d, n| { + found.contains(&n.id) && (members || !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound)) + }) } /// Document Setup's units (also `document.setup {units}`). @@ -364,6 +427,7 @@ fn undo_group_begin(s: &mut Session, p: &Value) -> Result { Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()), Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")), }; + s.explicit_undo_group = Some(label.clone()); s.begin_named_undo_group(label); ok() } @@ -390,7 +454,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> { fn undo(s: &mut Session, _: &Value) -> Result { settle_interaction(s)?; - let guides_locked = s.menu.guides_locked; + let menu = s.menu.clone(); let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); @@ -405,10 +469,8 @@ fn undo(s: &mut Session, _: &Value) -> Result { st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); - // Guides locked since the step was made stay deselected. - if guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + // Guides locked and slices locked or hidden since the step was made stay deselected. + crate::deselect_hidden(&menu, &st.doc, &mut st.selection); st.sync_new_art(); super::maskedit::follow_history(st, editing); leave_isolation_if_gone(st); @@ -418,7 +480,7 @@ fn undo(s: &mut Session, _: &Value) -> Result { fn redo(s: &mut Session, _: &Value) -> Result { settle_interaction(s)?; - let guides_locked = s.menu.guides_locked; + let menu = s.menu.clone(); let st = s.doc_mut()?; let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?; let label = e.label.clone(); @@ -433,9 +495,7 @@ fn redo(s: &mut Session, _: &Value) -> Result { st.history.trim(); st.revision += 1; st.selection.prune(&st.doc); - if guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + crate::deselect_hidden(&menu, &st.doc, &mut st.selection); st.sync_new_art(); super::maskedit::follow_history(st, editing); leave_isolation_if_gone(st); @@ -836,7 +896,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio /// document, each original and its copy before the copy goes in, and must leave the document's /// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside /// another root, are copied one at a time: each copy is made from the document as the copies -/// before it left it. +/// before it left it. Copied type whose layout caches [`Document::reid`] dropped has them again. pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result> { let paths = d.index_paths(roots.iter().copied()); let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect(); @@ -863,6 +923,7 @@ pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl Fn } out }; + super::typecmd::refresh_bounds_of(d, &out); Ok(out) } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index dc2e18054..33b73b2e5 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -692,9 +692,13 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let ids = s.edit("Expand Appearance", |d, _| { let brushes = vectorcraft_brush::library(d); let mut out = vec![]; + let threaded = super::threads::frames(d); for id in &roots { expand_node(d, *id, &brushes, &mut out); } + // Outlined frames of threaded text leave their threads with the text they show; type that + // stays type under a new id takes its frame's place. + super::threads::leave_threads(d, &threaded); if out.is_empty() { return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into())); } diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 02d8c7246..cf760ddda 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -11,8 +11,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { // The active artboard the app passes, for the steps that act on it. let artboard = p.get("artboard").and_then(Value::as_u64); // What an error rolls back besides the documents: session-level state a step may change - // (paint defaults, drawing mode, clipboard, the Untitled-N count). - let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter); + // (paint defaults, drawing mode, clipboard, the Untitled-N count, a begun undo group). + let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter, s.explicit_undo_group.clone()); // A drag or typing still in progress is a step of its own, as before Undo: the batch's steps // would otherwise run inside it and take its baseline as theirs (#1697). if s.active().is_some() { @@ -30,7 +30,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { Err(e) => { roll_back(s, start, &stash); s.active = active; - (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved; + (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved; s.pending_paint = None; return Err(e); } @@ -99,7 +99,8 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Va /// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection, /// history, file, format and saved state again (#1697: undoing the batch's interaction alone left /// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay -/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected. +/// written. Lock Guides, Lock Slices and Hide Slices stay as the steps left them, and the guides +/// and slices they keep from being selected are deselected. fn roll_back(s: &mut Session, start: Vec, stash: &[DocState]) { let open = std::mem::take(&mut s.docs); s.docs = start; @@ -107,8 +108,6 @@ fn roll_back(s: &mut Session, start: Vec, stash: &[DocState]) { // Past the newest revision the document reached, so the restored one redraws. let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision); st.revision = newest.saturating_add(1); - if s.menu.guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection); } } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 5aa3fecad..5a5489863 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -1,6 +1,7 @@ //! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As //! → EPS Options): the options parsed from the params, transparency flattened with a flattener -//! preset, one file of the art's bounds or one per artboard, the preview and thumbnail rendered, +//! preset, one file of the art's bounds or one per artboard (with a central file of the whole +//! document when there are several), the preview and thumbnail rendered, //! and the native document embedded for reopening. use std::borrow::Cow; @@ -28,7 +29,7 @@ pub fn specs() -> Vec { "Export EPS", [], None, - "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}", + "{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}", has_doc, export_eps )] @@ -43,7 +44,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[ name: "useArtboards", ty: "boolean", default: "null", - description: "true: one file per chosen artboard (default all), _.eps; false or absent: one file of the visible art (naming artboards also uses them)", + description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .eps of the whole document; false or absent: one file of the visible art (naming artboards also uses them)", }, FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" }, FormatOption { @@ -197,7 +198,8 @@ fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Re } /// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner -/// as the origin, or the visible art's bounds with the first artboard's corner. +/// as the origin, or the visible art's bounds with the first artboard's corner. With two or more +/// chosen artboards, a central file of the visible art comes first (none when no art shows). fn pages(doc: &Document, set: &Settings) -> Result, Rect, Point)>> { let n = doc.artboards.len(); let named = set.boards.resolve(n).map_err(|e| bad(C, e))?; @@ -208,14 +210,18 @@ fn pages(doc: &Document, set: &Settings) -> Result, Rect, Poi (None, None) => None, }; let corner = |r: Rect| Point::new(r.x0, r.y1); + let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect))); match boards { - Some(boards) => boards - .into_iter() - .map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))) - .collect(), + Some(boards) => { + let central = (boards.len() > 1).then(|| vectorcraft_render::encode::art_bounds(doc).map(art)).flatten(); + let each = boards + .into_iter() + .map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))); + central.map(Ok).into_iter().chain(each).collect() + } None => { - let art = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - Ok(vec![(None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)))]) + let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; + Ok(vec![art(bounds)]) } } } @@ -251,8 +257,8 @@ fn native(doc: &Document, board: Option, include_linked: bool) -> Vec } /// Encode `doc` as EPS: one file of the visible art, or with Use Artboards one per chosen -/// artboard. Transparency is flattened first (once for all of them); the previews show `doc` -/// itself. +/// artboard (after a central file of the whole document when there are several). Transparency is +/// flattened first (once for all of them); the previews show `doc` itself. pub(super) fn encode(doc: &Document, p: &Value) -> Result { let set = parse(p)?; let original = doc; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 302240baa..c6e759988 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -323,6 +323,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); } else if !doc.text_threads.is_empty() { crate::cmd::threads::share_optical_margin(&mut doc); + // A frame whose first paragraph continues from the frame before draws it without the + // paragraph's first-line indent and list marker. + crate::cmd::threads::mark_continued(&mut doc); } if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) { super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays); @@ -463,8 +466,12 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Vec { "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; 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", + "{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; 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 ), diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 4f642c74a..bcca24c34 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -121,7 +121,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "outlineText", ty: "boolean", default: "false", - description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines", + description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is always outlines", }, ]; @@ -146,10 +146,13 @@ pub(super) struct Extras { /// are left to [`encode`]). pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> { let o: NativeOptions = super::encode::options(f, p)?; - let mut so = SaveOptions::for_doc(doc); - so.compress = o.compress.unwrap_or(false); - so.version = o.version.unwrap_or(vectorcraft_format::VERSION); - so.include_linked = o.include_linked; + // Pretty or compact is decided by [`encode_to`] on the document it writes. + let mut so = SaveOptions { + compress: o.compress.unwrap_or(false), + version: o.version.unwrap_or(vectorcraft_format::VERSION), + include_linked: o.include_linked, + ..SaveOptions::default() + }; if o.embed_profiles.unwrap_or(true) { so.profiles = embedded_profiles(doc); } @@ -190,6 +193,7 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp } other => other, }; + so.pretty = so.pretty_fits(&native); vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string())) } diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 170b55f88..3eb64f3ea 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -14,7 +14,8 @@ use vectorcraft_doc::Document; use super::super::*; use super::{ - ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, + ARTBOARD_PARAMS, ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, + write_encoded, }; use crate::{DocState, Prefs}; @@ -94,6 +95,9 @@ pub struct SavePlan { /// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file /// stamps; `None`: the time it is written. pub modified: Option>, + /// Warnings about the params themselves: each native option (`compress`, `version`, + /// `preview`) given beside the path that the format doesn't read. + pub notes: Vec, } impl SavePlan { @@ -233,6 +237,14 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { (_, None, None) => checked(cmd, st.format)?, }; let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path }; + if let Some((key, top, inner)) = disagreeing_option(p, is_svg(format)).map_err(|e| bad(cmd, e))? { + return Err(bad(cmd, format!("`{key}: {top}` and `options.{key}: {inner}` disagree: give the option once"))); + } + let notes = NATIVE_OPTIONS + .iter() + .filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k)) + .map(|k| format!("`{k}` was not used: {} files don't read it", format.label)) + .collect(); let mut given: Map = match p.get("options") { Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(), None | Some(Value::Null) => Map::new(), @@ -253,7 +265,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { } } // None given: the ones the document was last saved with in this format. - let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given }; + let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given }; // Save writes the document's own file; the other modes only where they are told to. let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten()); @@ -272,12 +284,141 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { if let Some(path) = &path { super::check_not_lossy_overwrite(st, path, p, cmd)?; } - Ok(SavePlan { mode, path, format, options, name, folder, modified }) + Ok(SavePlan { mode, path, format, options, name, folder, modified, notes }) +} + +/// The native save options, which a save also takes beside the path. +const NATIVE_OPTIONS: [&str; 3] = ["compress", "version", "preview"]; + +/// The first option `p` gives both beside the path and in `options` with different values, as +/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on +/// either side is the one its `svg: {…}` object gives, else the flat one. +fn disagreeing_option(p: &Value, svg: bool) -> std::result::Result, String> { + let Some(inner) = p.get("options").filter(|o| o.is_object()) else { return Ok(None) }; + let given = |m: &Value| -> std::result::Result, String> { + let mut out: Map = m + .as_object() + .into_iter() + .flatten() + .filter(|(k, v)| !v.is_null() && !matches!(k.as_str(), "path" | "format" | "options") && !(svg && k.as_str() == "svg")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if svg { + out.extend(super::svg_options(m)?); + } + Ok(out) + }; + let top = given(p)?; + Ok(given(inner)?.into_iter().find_map(|(k, v)| { + let t = top.get(&k).filter(|t| !same_value(t, &v))?.clone(); + Some((k, t, v)) + })) +} + +/// `a == b`, with a whole number and a float of the same value (`3` and `3.0`) the same. +fn same_value(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x == y || ((x.is_f64() || y.is_f64()) && x.as_f64() == y.as_f64()), + _ => a == b, + } +} + +/// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`) +/// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is +/// left out, and one that moved is named by its new number. When none of the artboards the save +/// used is left, the pick is left out, and a save without options writes the artboards it writes +/// when none is named. `range` `"all"` stays as it is. Artboards given to a save are still checked. +pub fn remembered_save_options(st: &DocState) -> Map { + let mut options = st.save_options.clone(); + let ids: Vec = st.doc.artboards.iter().map(|a| a.id).collect(); + // The encoders refuse a document without artboards themselves. + if ids.is_empty() { + return options; + } + // Artboard `i` of the save, where it is now (by number when the save's artboards are unknown). + let then = &st.save_artboards; + let count = if then.is_empty() { ids.len() } else { then.len() }; + let now = |i: usize| match then.get(i) { + Some(id) => ids.iter().position(|x| x == id), + None => (then.is_empty() && i < ids.len()).then_some(i), + }; + // Each pick that names artboards: `Some` of where they are now (empty: all gone). + let mut moved: Vec<(&str, Option>)> = vec![]; + for k in ARTBOARD_PARAMS { + let named = match (k, options.get(k)) { + ("range", Some(Value::String(r))) if r.trim().is_empty() || r.trim().eq_ignore_ascii_case("all") => continue, + ("range", Some(Value::String(r))) => match vectorcraft_doc::range::parse_range(r, count) { + Ok(v) => v, + Err(_) => { + moved.push((k, Some(vec![]))); + continue; + } + }, + ("artboards", Some(v @ Value::Array(_))) => match serde_json::from_value::>(v.clone()) { + Ok(v) => v, + Err(_) => continue, + }, + ("artboard", Some(v)) => match v.as_u64().and_then(|i| usize::try_from(i).ok()) { + Some(i) => vec![i], + None => continue, + }, + _ => continue, + }; + let there: Vec = named.iter().filter_map(|i| now(*i)).collect(); + // A range that reads differently against the artboards there are now (`"2-"` once one + // was added) is written out as the artboards it named. + let reads = match (k, options.get(k)) { + ("range", Some(Value::String(r))) => vectorcraft_doc::range::parse_range(r, ids.len()).ok(), + _ => Some(named), + }; + moved.push((k, (reads.as_ref() != Some(&there)).then_some(there))); + } + // The pick a save uses (as `ArtboardPick::resolve` reads it: `range`, then `artboards`, then + // `artboard`) is gone: so is the rest. + let used = ["range", "artboards", "artboard"].into_iter().find(|k| match options.get(*k) { + Some(Value::String(r)) => !r.trim().is_empty(), + Some(v) => !v.is_null(), + None => false, + }); + let gone = |k: &str| moved.iter().any(|(m, there)| *m == k && there.as_ref().is_some_and(Vec::is_empty)); + if used.is_some_and(gone) { + for k in ARTBOARD_PARAMS { + options.remove(k); + } + return options; + } + for (k, there) in moved { + let Some(there) = there else { continue }; + let value = match (k, there.first()) { + (_, None) => None, + ("range", Some(_)) => Some(json!(range_text(&there))), + ("artboards", Some(_)) => Some(json!(there)), + (_, Some(i)) => Some(json!(i)), + }; + match value { + Some(v) => _ = options.insert(k.into(), v), + None => _ = options.remove(k), + } + } + options +} + +/// 0-based artboard indices as a 1-based range in the same order, runs joined (`"1-2, 5"`). +fn range_text(picked: &[usize]) -> String { + let mut runs: Vec<(usize, usize)> = vec![]; + for &i in picked { + match runs.last_mut() { + Some((_, end)) if *end + 1 == i => *end = i, + _ => runs.push((i, i)), + } + } + let run = |&(a, b): &(usize, usize)| if a == b { format!("{}", a + 1) } else { format!("{}-{}", a + 1, b + 1) }; + runs.iter().map(run).collect::>().join(", ") } /// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General -/// settings (`createLayers`…) and presets. -fn reads_option(f: &Format, key: &str) -> bool { +/// settings (`createLayers`…) and presets. A save drops each option its format doesn't read. +pub fn reads_option(f: &Format, key: &str) -> bool { f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key))) } @@ -426,6 +567,15 @@ fn blank_pages(doc: &Document) -> Document { /// Why a `.ai` file saved without PDF content looks empty elsewhere. const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; +/// `e`, an error of the export encoders, under the save command `cmd` instead of +/// `document.export`. +fn named_for_save(cmd: &str, e: crate::EngineError) -> crate::EngineError { + match e { + crate::EngineError::BadParams { cmd: c, msg } if c == "document.export" => bad(cmd, msg), + e => e, + } +} + /// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the /// same file). pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { @@ -509,7 +659,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option Result { let mut enc = self.encode_one(&self.doc)?; for &b in &self.boards { let one = self.encode_one(&artboard_doc(&self.doc, b))?; enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes))); } - // Lost artboards first: the status bar shows only the first warning. + enc.warnings.splice(0..0, self.format_losses()); + enc.warnings.extend(self.plan.notes.iter().cloned()); + Ok(enc) + } + + /// What the file loses against the document: the artboards an SVG file leaves out first (the + /// status bar shows only the first warning), then what the format loses. + fn format_losses(&self) -> impl Iterator { let f = self.plan.format; let lost = if is_svg(f) { super::svg::lost_artboards_warning(&self.doc, &self.params, f.label) } else { None }; - enc.warnings.splice(0..0, lost.into_iter().chain(fidelity_warning(f))); - Ok(enc) + lost.into_iter().chain(fidelity_warning(f)) + } + + /// The warnings known before the file is encoded, in the order a written file's reply gives + /// them: what the format loses, then the plan's notes on its params (a reply sent before the + /// file is written, as Background Save's, gives these). + pub fn known_warnings(&self) -> Vec { + self.format_losses().chain(self.plan.notes.iter().cloned()).collect() } /// Encode one file of `doc` in the job's format. fn encode_one(&self, doc: &Document) -> Result { let (cmd, f) = (self.plan.mode.command(), self.plan.format); - let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? }; + let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params) } else { encode_all(doc, f.id, &self.params) } + .map_err(|e| named_for_save(cmd, e))?; // A legacy .ai file per artboard. let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?; if enc.files.len() != 1 && !per_artboard { @@ -650,8 +814,7 @@ impl SaveJob { }; let r = stream(path, &mut fill); r.map_err(|e| failed.take().unwrap_or(e))?; - let warnings: Vec = fidelity_warning(f).into_iter().collect(); - Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings })) + Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": self.known_warnings() })) } /// Once written: Save and Save As (except to a template) make the file `st`'s own: path, @@ -664,6 +827,7 @@ impl SaveJob { st.path = Some(path); st.format = self.plan.format.id; st.save_options = self.plan.options; + st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect(); st.converted = false; st.recovered = false; if let Some(at) = self.stamped { @@ -756,14 +920,14 @@ fn format_options(s: &mut Session, p: &Value) -> Result { let f = format(id) .filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id)) .ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?; - let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options); + let saved = st.filter(|d| d.format == f.id).map(remembered_save_options); // A native save compresses as Use Compression says, unless told otherwise. let prefs = with_compression_pref(&s.prefs, &json!({})); let mut v = f.to_json(); if let Some(options) = v["options"].as_object_mut() { for (name, o) in options.iter_mut() { let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]); - o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(default).clone(); + o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone(); } } v["saveFormats"] = @@ -778,7 +942,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead, compress?: bool (native, also in options: gzip; default: the useCompression preference), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index e656965e1..530b681ba 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -702,6 +702,58 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() { let _ = std::fs::remove_dir_all(&d); } +/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different +/// values is refused before anything is written; `compress`, `version` or `preview` beside the path of a format that doesn't +/// read it is named in the warnings. +#[test] +fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() { + let d = tmp_dir("save-options"); + let mut s = session(200.0, 100.0, 1); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let path = d.join("both.vectorcraft").to_string_lossy().to_string(); + for cmd in ["document.save", "file.saveAs", "file.saveCopy", "file.saveAsTemplate"] { + let e = s.execute(cmd, &json!({"path": path, "compress": true, "options": {"compress": false}})).unwrap_err().to_string(); + assert!(e.contains("`compress: true` and `options.compress: false` disagree") && e.contains(&format!("`{cmd}`")), "{cmd}: {e}"); + } + assert!(!std::path::Path::new(&path).exists(), "nothing is written"); + assert!(!d.join("both.vctemplate").exists(), "nor a template"); + // An SVG option given in `svg: {…}` or in `options.svg` counts as given there. + let svg = d.join("a.svg").to_string_lossy().to_string(); + for p in [ + json!({"svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}), + json!({"hiddenLayers": false, "options": {"svg": {"hiddenLayers": true}}}), + json!({"hiddenLayers": true, "svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}), + ] { + let mut p = p; + p["path"] = json!(svg); + let e = s.execute("file.saveCopy", &p).unwrap_err().to_string(); + assert!(e.contains("`hiddenLayers: false` and `options.hiddenLayers: true` disagree") && e.contains("`file.saveCopy`"), "{p}: {e}"); + } + assert!(!std::path::Path::new(&svg).exists(), "nothing is written"); + // Agreeing values, or one of them null, save as asked; `3` and `3.0` agree. + for p in [ + json!({"compress": true, "options": {"compress": true}}), + json!({"compress": null, "options": {"compress": true}}), + json!({"compress": true, "version": 3, "options": {"compress": true, "version": 3.0}}), + ] { + let mut p = p; + p["path"] = json!(path); + let r = s.execute("file.saveCopy", &p).unwrap(); + assert_eq!(r["warnings"], json!([]), "{p}: {r}"); + assert_eq!(std::fs::read(&path).unwrap()[..2], [0x1f, 0x8b], "{p}"); + } + // An SVG file reads none of the native options; a .ai file reads `compress` only. + let r = s.execute("file.saveCopy", &json!({"path": svg, "compress": true, "version": 3, "preview": null})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(w.contains(&"`compress` was not used: SVG files don't read it"), "{w:?}"); + assert!(w.contains(&"`version` was not used: SVG files don't read it") && !w.iter().any(|w| w.starts_with("`preview`")), "{w:?}"); + assert!(std::fs::read(&svg).unwrap().starts_with(b"<"), "plain SVG"); + let r = s.execute("file.saveCopy", &json!({"path": d.join("a.ai").to_string_lossy(), "compress": false, "version": 3})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(w.iter().any(|w| w.starts_with("`version` was not used")) && !w.iter().any(|w| w.starts_with("`compress`")), "{w:?}"); + let _ = std::fs::remove_dir_all(&d); +} + /// A native save whose file write fails partway (a full disk) leaves the file as it was, and no /// temporary file behind. #[test] diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0593dbf28..9ee98d960 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -82,6 +82,37 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() { assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); } +/// A `.ai` file opens with the swatches its palette holds, spot colours the art doesn't use and colour +/// groups included, in place of the default swatches; art painted with Registration (`XZ`) still +/// opens from the editing data. +#[test] +fn an_ai_file_opens_with_the_swatches_of_its_palette() { + let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\ + 0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n"; + // The square the page draws, in the red ink, after a stroke colour of Registration. + let art = format!("0 0 0 0 0 0 0 ([Registration]) 0 1 XZ\n0.06 0.98 1 0 (Red ink) 0 x\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0)); + let data = ai::editing_data(200.0, 100.0, &format!("{palette}{}", ai::layer("Art", &art))); + let mut s = Session::new(); + let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap(); + assert_eq!(r["format"], json!("ai"), "{r}"); + let doc = &s.doc().unwrap().doc; + assert_eq!(names(&doc.layers), ["Art"], "opened from the editing data: {r}"); + let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"]); + let pink = doc.swatch("Pink ink").unwrap(); + assert!(pink.spot && pink.global); + assert_eq!(pink.paint.color(), Some(vectorcraft_color::Color::lab(81.96, 21.0, -8.0)), "a Lab ink keeps its Lab values"); + let groups: Vec<&str> = doc.swatch_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(groups, ["Inks"]); + assert!(doc.swatch("Yellow ink").is_some_and(|s| s.spot), "an ink the art doesn't use"); + let red = &doc.layers[0].children().unwrap()[0]; + assert!( + matches!(red.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Solid { swatch: Some(n), .. }) if n == "Red ink"), + "{:?}", + red.appearance.fill() + ); +} + /// A 1-bit RGB image opens from the editing data in the colors its PDF part shows. #[test] fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() { diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 681d99852..2ab0aae0c 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -80,8 +80,13 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() { let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": true, "previewFormat": "none"})).unwrap(); let files = r["files"].as_array().unwrap(); let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect(); - assert_eq!(names, ["Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]); - for f in files { + assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]); + // The central file comes first: the whole document, bounded by the visible art of both artboards. + let central = b64(&files[0]); + assert_eq!(dsc(&postscript(¢ral), "BoundingBox").as_deref(), Some(format!("10 60 {} 90", second + 40.0).as_str())); + let native = vectorcraft_format::load(&vectorcraft_eps::native(¢ral).unwrap()).unwrap(); + assert_eq!((native.artboards.len(), native.layers[0].children().unwrap().len()), (2, 2)); + for f in &files[1..] { let bytes = b64(f); let ps = postscript(&bytes); assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("0 0 100 100")); @@ -89,6 +94,10 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() { let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap(); assert_eq!(native.artboards.len(), 1); } + // Named artboards, two or more, get the central file too. + let r = s.execute("document.exportEps", &json!({"artboards": [1, 0], "previewFormat": "none"})).unwrap(); + let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-2.eps", "Untitled-1_Artboard-1.eps"]); // A range names the artboards (useArtboards implied); one file keeps the path's name. let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap(); assert!(r.get("files").is_none()); @@ -97,6 +106,30 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() { // Without art there is nothing to bound. let mut empty = session(100.0, 100.0, 1); assert!(empty.execute("document.exportEps", &json!({})).unwrap_err().to_string().contains("nothing to export")); + // Artboards without art still get their files, but no central file. + let mut empty = session(100.0, 100.0, 2); + let r = empty.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "none"})).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); +} + +#[test] +fn use_artboards_writes_the_central_file_beside_the_artboard_files() { + let mut s = session(100.0, 100.0, 2); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-epsboards-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("logo.eps").to_string_lossy().to_string(); + s.execute("document.exportEps", &json!({"path": path, "useArtboards": true, "previewFormat": "none"})).unwrap(); + let mut written: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + written.sort(); + assert_eq!(written, ["logo.eps", "logo_Artboard-1.eps", "logo_Artboard-2.eps"]); + let central = std::fs::read(&path).unwrap(); + assert_eq!(dsc(&postscript(¢ral), "BoundingBox").as_deref(), Some("10 60 40 90")); + let _ = std::fs::remove_dir_all(dir); + // Export for Screens still writes one EPS per artboard, with no central file. + let rows = json!({"formats": [{"format": "eps", "useArtboards": true}], "settings": {"eps": {"useArtboards": true}}}); + let r = s.execute("document.exportForScreens", &rows).unwrap(); + assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); } #[test] diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index d4e8b5ad1..741921ea1 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "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 → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{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 ), @@ -268,7 +268,14 @@ fn flatten(s: &mut Session, p: &Value) -> Result { 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| apply(d, sel, plan, &o))?; + 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); diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 603a2d09c..a861eeb34 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -586,7 +586,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { let stroke = edits_stroke(s, p, item, !s.fill_active, C)?; let item = item.of_kind(s, !stroke); let targets = match item { - ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?, + ItemTarget::Top => { + leaf_targets(s, &super::given_objects(s, p, C)?.map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)? + } _ => item.targets(s, p)?, }; if targets.is_empty() { diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 3681aa261..cd8fa7b36 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -806,7 +806,7 @@ fn column_art( if length(rect) <= 1e-9 || !length(rect).is_finite() { return None; } - let cost = c.art.count(); + 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). @@ -2076,7 +2076,7 @@ fn design(s: &mut Session, p: &Value) -> Result { 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() > GraphDesign::MAX_NODES { + 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. diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 5c31ca009..05ec602e6 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -768,6 +768,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(parent)?; let index = index.min(ch.len()); ch.splice(index..index, nodes); + // Copied type whose layout caches `reid` dropped has them again. + if copy { + super::typecmd::refresh_bounds_of(d, &out); + } sel.prune(d); sel.deselect_uneditable(d); Ok(out) @@ -866,6 +870,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result { // The last first, so the others keep their places; their guides go with them. for &i in indices.iter().rev() { let id = d.artboards.remove(i).id; + d.retire_artboard_id(id); d.retain_guides(sel, |_, g| g.artboard != Some(id)); } Ok(()) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index c302a7409..5a0e2ec80 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -388,6 +388,10 @@ fn release(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(Some(parent))?; let at = index.min(ch.len()); ch.splice(at..at, layers); + // Copied type whose layout caches `reid` dropped has them again. + if build { + super::typecmd::refresh_bounds_of(d, &new); + } sel.prune(d); Ok(new) })?; diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index c70d423d7..bfa7e779e 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -96,7 +96,15 @@ impl Visitor for LinkFiles { if let Some((Some(saved_size), Some(saved_hash))) = self.saved.get(i) && *saved_size == size && size <= MAX_HASHED - && hash.get_or_insert_with(|| std::fs::File::open(path).and_then(vectorcraft_doc::links::hash_reader).ok()).as_ref() + && hash + .get_or_insert_with(|| { + // Only the size looked at is read: a file growing meanwhile no longer + // matches, rather than being hashed to its new end. + let file = std::fs::File::open(path).ok()?; + let (hash, read) = vectorcraft_doc::links::hash_reader_within(file, size).ok()?; + (read == size).then_some(hash) + }) + .as_ref() == Some(saved_hash) { items.push(n + i); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index e8f122e96..ef5be1322 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -231,7 +231,12 @@ impl Found { /// `base` pointing at this file as read now (`bytes`). fn link(&self, bytes: &[u8], base: &LinkInfo) -> LinkInfo { - LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash_bytes(bytes)), ..base.clone() } + self.link_hashed(hash_bytes(bytes), base) + } + + /// [`Self::link`] with the bytes' hash already taken. + fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo { + LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() } } } @@ -542,8 +547,11 @@ fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> { /// Replace image `id` with the raster at `path`, keeping the object's bounds (what an image /// variable does on `dataset.select`). The file is linked; a missing or unreadable path is an /// error so the dataset application can skip that binding. -pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str) -> Result<()> { - let path = absolute_path(path); +pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_path: Option<&str>) -> Result<()> { + // A path stored in the document (a data set's value) is followed as its other stored paths + // are: on this computer or the document's own share only, so choosing a data set can't make + // the app connect to another computer. + let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?; let bytes = read_file(&path)?; let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?); install(d, &[id], read, Some(Preserve::Bounds)) @@ -618,6 +626,10 @@ impl Resolved { /// image's bounds, which isn't a change to report. Nothing here is an undo step. pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); + // The image (with its preview) and hash of each file read for a preview here, by path: images + // of other keys linking to the same file share them, rather than each reading, decoding and + // keeping a copy of its own. + let mut read_images: BTreeMap = BTreeMap::new(); for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); @@ -655,14 +667,24 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p } (Status::Ok, Some(mut f)) => { let preview = previewing(d, &g.key); + if let Some((blob, hash)) = read_images.get(&f.path).filter(|_| preview && !eps_preview).cloned() { + store_image(d, &g.key, blob, true); + let link = f.link_hashed(hash, &g.link); + if link != g.link { + set_links(d, &g.ids, &link); + } + continue; + } // Read when the images show the preview, or when the file's details changed. let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None }; if preview && !eps_preview { - let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else { + let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, fileio::raster_image(b).ok()?))) else { out.missing.extend(row); continue; }; - store_image(d, &g.key, img.blob, true); + let blob = img.blob.with_proxy(); + read_images.insert(f.path.clone(), (blob.clone(), hash_bytes(b))); + store_image(d, &g.key, blob, true); } else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) { // Linked before links kept a preview. let b = b.clone().with_proxy(); @@ -1266,6 +1288,11 @@ fn placement_json(o: PlacementOptions) -> Value { /// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { + // Within what the caller that opened the document may read, whoever asks. + crate::file_access::confine(st.opened_by.as_ref(), || file_fields_here(st, id, l, out)); +} + +fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0); let doc_path = st.path.as_deref(); let path = fileio::document_path(&l.path, doc_path).ok(); diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 54d21415c..e9283f075 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -140,6 +140,8 @@ pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result let copy = d.reid(part); ids.push(d.insert(Some(layer), usize::MAX, copy)?); } + // Copied type whose layout caches `reid` dropped has them again. + super::typecmd::refresh_bounds_of(d, &ids); d.mask_edit = Some(MaskEdit { object: id, layer }); sel.set(ids); Ok(layer) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index fe0cb1c52..c9ff3f455 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -969,10 +969,12 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { if b.width() <= 0.0 || b.height() <= 0.0 { continue; } + // Named one past the highest artboard id the document has, whatever id it is given. + let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1)); let aid = d.next_artboard_id(); d.artboards.push(vectorcraft_doc::Artboard { id: aid, - name: format!("Artboard {aid}"), + name: format!("Artboard {number}"), rect: b, locked: false, show_center_mark: false, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c7414ed90..9c5d38158 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -367,6 +367,22 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option { pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) } +/// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads +/// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error, +/// never the selection. `None` when it gives neither (or gives them as `null`). +pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result>> { + if p.get("ids").is_some_and(|v| !v.is_null()) { + return checked_ids_param(s, p, "ids", command).map(Some); + } + match p.get("id").filter(|v| !v.is_null()) { + None => Ok(None), + // A step reference a batch couldn't resolve, as `targets` reports it, under the command. + Some(Value::String(r)) if r.starts_with('$') => { + Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) + } + Some(v) => Ok(Some(vec![checked_id(s, v, command)?])), + } +} /// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list. pub(crate) fn index(v: u64) -> usize { usize::try_from(v).unwrap_or(usize::MAX) diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index cd695a81a..588307f20 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -517,6 +517,7 @@ pub const CATEGORIES: &[Category] = &[ ("Logo 1000×1000", 1000.0, 1000.0, PX), ("Business Card 3.5 × 2 in", 3.5, 2.0, IN), ("Business Card 85 × 55 mm", 85.0, 55.0, MM), + ("Business Card 91 × 55 mm", 91.0, 55.0, MM), ("Letterhead Letter", 8.5, 11.0, IN), ("Letterhead A4", 210.0, 297.0, MM), ("Envelope DL", 220.0, 110.0, MM), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c559b4bff..22ef28d13 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1139,16 +1139,14 @@ fn isolate(s: &mut Session, p: &Value) -> Result { } fn exit_isolation(s: &mut Session, _: &Value) -> Result { - let guides_locked = s.menu.guides_locked; + 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). - if guides_locked { - crate::deselect_guides(&st.doc, &mut st.selection); - } + 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); } diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index de1bf95ab..7a97ab871 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -237,6 +237,8 @@ fn release(s: &mut Session, p: &Value) -> Result { released.put(d, *id, parts)?; } released.finish(d)?; + // Copied type whose layout caches `reid` dropped has them again. + super::typecmd::refresh_bounds_of(d, &out); sel.set(out); Ok(()) })?; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 8636ffff1..e7c963d02 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -684,7 +684,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { /// active item of the proxy's kind is recoloured in each selected object's own stack instead. fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { let stroke = stroke_param(s, p, cmd)?; - let ids = match ids_param(p, "ids") { + let ids = match super::given_objects(s, p, cmd)? { Some(ids) => ids, None => super::edit::selected_roots(s)?, }; diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b99e6d9b0..b2a2aaec9 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\" (Paragraph panel › Bullets: each paragraph of a bulleted list starts with its bullet, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the bullet, the left indent counting as a stop; list \"bullet\" keeps a list's bullet, else a dot; \"none\" in either key turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -208,6 +208,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result Result; 3]>> { @@ -322,6 +334,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "spaceAfter", "list", "bullet", + "number", "hyphenate", "wordsLongerThan", "afterFirst", @@ -420,11 +433,18 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`kashida` must be \"none\", \"short\", \"medium\", \"long\" or \"stylistic\"")), }), }; - // Bullets: `list` turns a bulleted list on or off and `bullet` picks its bullet; "none" in - // either turns the list off. - let list = super::object::choice(p, C, "list", &["none", "bullet"])?; + // Bullets and numbers: `list` turns a bulleted or numbered list on or off, `bullet` picks the + // bullet and `number` the number style; "none" in any of them turns the list off. + let list = super::object::choice(p, C, "list", &["none", "bullet", "number"])?; let bullet = super::object::choice(p, C, "bullet", &["none", "dot", "dash", "circle", "square"])? .map(|b| BULLETS.iter().find(|(k, _)| *k == b).map(|&(_, bullet)| bullet)); + let number = super::object::choice( + p, + C, + "number", + &["none", "arabic", "upperLetters", "lowerLetters", "upperRoman", "lowerRoman", "twoDigits", "threeDigits"], + )? + .map(|n| NUMBERS.iter().find(|(k, _)| *k == n).map(|&(_, style)| style)); let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); @@ -522,10 +542,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = num("spaceAfter") { para.space_after = v.clamp(-MAX_PARA, MAX_PARA); } - if list.is_some() || bullet.is_some() { - let on = list != Some("none") && bullet != Some(None); + if list.is_some() || bullet.is_some() || number.is_some() { + let on = list != Some("none") && bullet != Some(None) && number != Some(None); let had = para.list; - para.list = on.then(|| vectorcraft_doc::text::ParaList::bulleted(bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default())); + // A numbered list for list "number", or without `list` for a number style. + let numbered = list == Some("number") || (list.is_none() && number.is_some()); + para.list = on.then(|| vectorcraft_doc::text::ParaList { + bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), + number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()), + }); // A paragraph that joins a list with no left or first-line indent gets a // hanging indent, unless the call sets an indent. if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index a0b13db2e..1c8ba5ffc 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -161,6 +161,32 @@ pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool { doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key)) } +/// Does the description `doc` take `id` as `ids` of one object (`{ids?|id?}`, `{id?, ids?: … +/// (instead of id)}`, `{id} or {ids: […]}`): it names both keys, and no params object of it names +/// `ids` beside a required `id`, which is something else (`plugin.run {id, ids?}`: the plug-in's)? +pub(crate) fn id_stands_for_ids(doc: &'static str) -> bool { + fn head(alternative: &str) -> &str { + alternative.split([':', '=', '(']).next().unwrap_or_default().trim() + } + if !takes_key(doc, "ids") || !takes_key(doc, "id") { + return false; + } + let mut rest = doc; + while let Ok((body, after)) = object_body(rest) { + let entries = split_top(body, ','); + let required_id = entries.iter().any(|e| split_top(e, '|').len() == 1 && head(e) == "id"); + let names_ids = entries.iter().any(|e| split_top(e, '|').iter().any(|a| head(a).trim_end_matches('?').trim_end() == "ids")); + if required_id && names_ids { + return false; + } + match after.trim_start().strip_prefix("or ").or_else(|| after.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')) { + Some(next) => rest = next, + None => break, + } + } + true +} + /// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case. fn distance(a: &str, b: &str) -> usize { let (a, b): (Vec, Vec) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect()); @@ -202,7 +228,7 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> { /// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of /// them, or all of them. -fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError { +pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError { bad( id, match nearest(key, keys) { diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 49924c5c8..650ff9992 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -126,8 +126,13 @@ pub(crate) fn document_art( return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd); } let mut l = fileio::load(name, bytes)?; - // Its linked images show their files; its placed documents stay as they are. - super::super::links::resolve(&mut l.doc, path, false, false); + // Its linked images show their files; its placed documents stay as they are, unless their + // stored bytes are lost, when resolving reads their files, which can place this one again. + // A document already being read further up, or past [`MAX_NESTED_READS`] of them, has its links + // left as stored. + if let Some(_reading) = Reading::enter(bytes) { + super::super::links::resolve(&mut l.doc, path, false, false); + } l.doc.drop_edit_modes(); let n = l.doc.artboards.len(); let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect); @@ -140,6 +145,33 @@ pub(crate) fn document_art( Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings }) } +/// The most placed documents read inside one another while their links are resolved. +const MAX_NESTED_READS: usize = 8; + +std::thread_local! { + /// The hashes of the documents whose links are being resolved on this thread, outermost first. + static READING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// A document's links being resolved ([`READING`]): the entry goes when this does. +struct Reading; + +impl Reading { + /// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are. + fn enter(bytes: &[u8]) -> Option { + let hash = hash_bytes(bytes); + READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading) + } +} + +impl Drop for Reading { + fn drop(&mut self) { + READING.with_borrow_mut(|r| { + r.pop(); + }); + } +} + /// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads). fn group(nodes: Vec, clip: Option) -> Node { let mut children: Vec> = nodes.into_iter().map(Arc::new).collect(); @@ -159,8 +191,12 @@ pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Documen /// `p`'s file read again, while it is the file it was read from (how the document model gets it /// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]). pub(crate) fn read_file_again(p: &PlacedDocument) -> Option> { + // The stored path is followed as stored paths are, but with no document to share a network + // folder with: a file on another computer is never read here, even one resolving refused + // (which leaves the stored path in place), so embedding or exporting can't connect to it. + let path = fileio::document_path(&p.link.path, None).ok()?; // One byte over the limit tells a file that is too big, without reading it all. - let bytes = fileio::read_file_head(&p.link.path, MAX_FILE + 1).ok()?; + let bytes = fileio::read_file_head(&path, MAX_FILE + 1).ok()?; if bytes.len() > MAX_FILE { return None; } diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index bf394f63c..c67c77ca4 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -77,6 +77,10 @@ const MAX_BASED_ON: usize = 32; const MAX_FONTS: usize = 4096; /// The most bytes of a font name kept. const MAX_NAME: usize = 256; +/// The most bytes of a style id: a longer one (Word's are under 256 characters) is ignored, both +/// where it is defined and where it is used, since every paragraph and run looks its style up by +/// it. +const MAX_STYLE_ID: usize = 256; /// The most paragraphs placed type takes from one Word document. const MAX_PARAGRAPHS: usize = 100_000; /// The most runs of one character formatting placed type takes from one Word document. @@ -484,7 +488,7 @@ impl Reader { } } Some("style") if self.styles.len() < MAX_STYLES => { - let Some(id) = attr(n, "styleId") else { continue }; + let Some(id) = attr(n, "styleId").filter(|id| id.len() <= MAX_STYLE_ID) else { continue }; let default = match attr(n, "type") { Some("paragraph") => Some(&mut self.default_style), Some("character") => Some(&mut self.default_char_style), @@ -495,7 +499,7 @@ impl Reader { } let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default(); let para = child(n, "pPr").map(para_props).unwrap_or_default(); - let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string); + let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).filter(|v| v.len() <= MAX_STYLE_ID).map(str::to_string); self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para }); } _ => {} @@ -637,7 +641,12 @@ impl Reader { fn paragraph(&mut self, p: Node, depth: usize) { self.flush(); let ppr = child(p, "pPr"); - let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone()); + let style = ppr + .and_then(|n| child(n, "pStyle")) + .and_then(|n| attr(n, "val")) + .filter(|v| v.len() <= MAX_STYLE_ID) + .map(str::to_string) + .or_else(|| self.default_style.clone()); let mut para = Para::default(); para.apply(&self.default_para); let mut base = Chars::default(); @@ -688,7 +697,11 @@ impl Reader { fn run(&mut self, r: Node) { let rpr = child(r, "rPr"); let mut chars = self.base; - let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref()); + let style = rpr + .and_then(|n| child(n, "rStyle")) + .and_then(|n| attr(n, "val")) + .filter(|v| v.len() <= MAX_STYLE_ID) + .or(self.default_char_style.as_deref()); for s in self.chain(style) { chars.apply(&s.run, true); } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 907e8fcd6..574b2d5f7 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -543,7 +543,9 @@ impl Session { self.prefs = p; if fonts_folder_changed { let folder = self.prefs.fonts_folder.trim(); - vectorcraft_text::set_user_font_dirs(if folder.is_empty() { vec![] } else { vec![folder.into()] }); + // A folder an automation caller chose is read without following its links out of it. + let confined = crate::file_access::current().is_some(); + vectorcraft_text::set_user_font_dirs_confined(if folder.is_empty() { vec![] } else { vec![folder.into()] }, confined); // Once the fonts were scanned, scan again now: the folder's fonts appear (or go) in the // font menus, and type in them lays out again. The first scan reads it anyway. if vectorcraft_text::FontDb::global().installed_fonts_changed() { diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 76f52f4b8..be17271c0 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Print", [], Some("Cmd+P"), - "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", + "{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out), forPrinter?: bool (the PDF goes to a print queue: a page whose paper is turned to landscape is written upright, the art turned a quarter anticlockwise, as a printer driver turns a landscape job; macOS prints a landscape page as it is, cut off on upright paper; paper wide in Portrait and transverse pages stay as they are)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device", has_doc, print ), @@ -187,7 +187,9 @@ fn print(s: &mut Session, p: &Value) -> Result { if !postscript { let printed = prepared.as_ref().unwrap_or(doc); let typed = type_warnings(&full, which, &areas(&vectorcraft_pdf::preview(printed, &set).map_err(|e| pdf_error(PRINT, e))?)); - let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?; + let mut r = + vectorcraft_pdf::print(printed, &PrintOptions { settings: set, for_printer: bool_or(p, "forPrinter", false), ..Default::default() }) + .map_err(|e| pdf_error(PRINT, e))?; r.warnings.extend(typed); return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index d9a8b487a..52431090c 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -1,15 +1,16 @@ -//! Edit → Edit Colors → Recolor Artwork: list the selection's colours, reduce them to rows of +//! Edit → Edit Colors → Recolor Artwork: list the colors of the selection (or of the art Select All +//! takes when nothing is selected), reduce them to rows of //! similar colours, and remap each row to a new colour by a recolour method. Colours are keyed by //! their identity ([`ColorKey`]: model and exact values), so a CMYK colour stays CMYK and two //! colours that only look alike stay apart. -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; use vectorcraft_color::recolor::{ColorKey, Method, Preserve, Rng, RowMap, cluster}; use vectorcraft_color::{Color, Paint, Swatch}; -use vectorcraft_doc::swatches::node_colors; -use vectorcraft_doc::{AppearanceItem, Document, NodeId, NodeKind}; +use vectorcraft_doc::swatches::{node_colors, node_paints}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::colorcmds::{Recolor, Scope}; use super::*; @@ -21,8 +22,8 @@ pub fn specs() -> Vec { "Artwork Colors", [], None, - "{} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} unique colours used by the selection (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first", - has_selection, + "{ids?: object ids (default: the selection, or with nothing selected the objects select.all takes, without the hidden and locked objects inside them)} → {colors: [{key: colour identity (\"rgb 255 0 0\", \"cmyk 0 100 100 0\", \"gray 40\", \"lab 55 60 -40\"), hex, count, swatch?: the global swatch it is linked to}]} unique colours used by the objects (fills, strokes, gradient stops, text, mesh points and the tiles of pattern fills and strokes), most used first", + has_doc, colors ), cmd!( @@ -30,7 +31,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], note?: set when a colour kept a model other than its new colour's (see model)}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model)}", has_doc, apply ), @@ -39,8 +40,8 @@ pub fn specs() -> Vec { "Reduce Colors", [], None, - "{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply; default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it)} group the selection's colours into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}", - has_selection, + "{colors?: n (rows; default: one per colour, with the tints of a global swatch in its row), method?: as recolor.apply; default scaleTints (picks each row's colour), preserve?: {white?: true, black?: true, grays?: false} (left out of the rows), limitTo?: as recolor.apply (new colours snap to it), ids?: as recolor.colors} group the colors of the objects (default: as recolor.colors) into rows of similar colours (k-means in Lab, weighted by use) → {map: [{from: [keys, the row's colour first], to: key}], preserved: [keys], method}", + has_doc, reduce ), cmd!( @@ -62,9 +63,19 @@ struct Used { swatch: Option, } -/// The unique colours of the selection, most used first (ties by key): fills, strokes, gradient -/// stops, text, mesh points and the tiles of the patterns used as fills or strokes. -fn used_colors(d: &Document, ids: &[NodeId]) -> Vec { +/// The unique colours of the objects `ids` and the objects inside them, most used first (ties by +/// key): fills, strokes, gradient stops, text, mesh points and the tiles of the patterns used as +/// fills or strokes. With `only`, an object not in it is left out with everything it holds. +fn used_colors(d: &Document, ids: &[NodeId], only: Option<&BTreeSet>) -> Vec { + fn read<'a>(n: &'a Node, only: Option<&BTreeSet>, out: &mut Vec<&'a Node>) { + if only.is_some_and(|o| !o.contains(&n.id)) { + return; + } + out.push(n); + for c in n.children().into_iter().flatten() { + read(c, only, out); + } + } let mut index: HashMap = HashMap::new(); let mut used: Vec<(ColorKey, Used)> = vec![]; let mut count = |c: &Color, link: Option<&str>| { @@ -81,25 +92,22 @@ fn used_colors(d: &Document, ids: &[NodeId]) -> Vec { }; // The patterns used as fills or strokes: their tiles count once each. let mut patterns: Vec<&str> = vec![]; + let mut nodes = vec![]; for n in ids.iter().filter_map(|id| d.node(*id)) { - node_colors(n, &mut count); - n.walk(&mut |m| { - let runs = match &m.kind { - NodeKind::Text(t) => t.runs.as_slice(), - _ => &[], - }; - let items = m.appearance.items.iter().map(|it| match it { - AppearanceItem::Fill(l) => &l.paint, - AppearanceItem::Stroke(l) => &l.paint, - }); - for p in items.chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) { - if let Paint::Pattern { pattern, .. } = p - && !patterns.contains(&pattern.as_str()) - { - patterns.push(pattern); - } + read(n, only, &mut nodes); + } + for m in nodes { + for p in node_paints(m) { + match p { + Paint::Solid { color, swatch, .. } => count(color, swatch.as_deref()), + Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| count(&s.color, s.swatch.as_deref())), + Paint::Pattern { pattern, .. } if !patterns.contains(&pattern.as_str()) => patterns.push(pattern), + _ => {} } - }); + } + if let NodeKind::Mesh(mesh) = &m.kind { + mesh.points.iter().for_each(|p| count(&p.color, None)); + } } for def in patterns.iter().filter_map(|p| d.pattern(p)) { def.art.iter().for_each(|n| node_colors(n, &mut count)); @@ -108,9 +116,30 @@ fn used_colors(d: &Document, ids: &[NodeId]) -> Vec { used.into_iter().map(|(_, u)| u).collect() } -fn colors(s: &mut Session, _: &Value) -> Result { +/// The objects whose colors `recolor.colors` and `recolor.reduce` read, for [`used_colors`]: `ids` +/// (each checked) or else the selection, with everything they hold; with nothing selected, the +/// objects Select All takes and the objects inside them, leaving out every hidden or locked object +/// with what it holds and the objects of hidden, locked and template layers (the second value +/// lists the objects read). Objects listed twice or inside another one listed are dropped, so +/// their colors count once. +fn scope_ids(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, Option>)> { let st = s.doc()?; - let v: Vec = used_colors(&st.doc, &st.selection.objects) + let ids = match p.get("ids") { + Some(_) => checked_ids_param(s, p, "ids", cmd)?, + None if st.selection.is_empty() => { + let only = super::select::selectable_ids(&st.doc, st.isolation); + return Ok((super::select::selectable(&st.doc, st.isolation), Some(only))); + } + None => st.selection.objects.clone(), + }; + let mut seen = BTreeSet::new(); + Ok((super::edit::outermost(&st.doc, ids).into_iter().filter(|id| seen.insert(*id)).collect(), None)) +} + +fn colors(s: &mut Session, p: &Value) -> Result { + let (ids, only) = scope_ids(s, p, "recolor.colors")?; + let st = s.doc()?; + let v: Vec = used_colors(&st.doc, &ids, only.as_ref()) .iter() .map(|u| { let mut v = json!({"key": key_str(&u.color), "hex": u.color.to_hex(), "count": u.count}); @@ -145,8 +174,9 @@ fn reduce(s: &mut Session, p: &Value) -> Result { let def = Preserve::default(); let flag = |k: &str, d: bool| p.get("preserve").map_or(d, |v| bool_or(v, k, d)); let preserve = Preserve { white: flag("white", def.white), black: flag("black", def.black), grays: flag("grays", def.grays) }; + let (ids, only) = scope_ids(s, p, C)?; let st = s.doc()?; - let (kept, used): (Vec, Vec) = used_colors(&st.doc, &st.selection.objects).into_iter().partition(|u| preserve.keeps(&u.color)); + let (kept, used): (Vec, Vec) = used_colors(&st.doc, &ids, only.as_ref()).into_iter().partition(|u| preserve.keeps(&u.color)); // Units that stay together: the tints of a global swatch, else one colour each. let mut units: Vec> = vec![]; for u in &used { @@ -261,11 +291,23 @@ struct Link { color: Color, } +/// A global swatch of the color group `recolor.apply` rewrites: its name, the color it is +/// rewritten with and the color art linked to it shows afterwards. +struct GroupSwatch { + name: String, + color: Color, + shown: Color, +} + /// A parsed map ready to recolour: the row of each current colour, and the swatch of each row's /// new colour. struct ColorMap { rows: Vec, links: Vec>, + /// The new color of each of `rows`. + tos: Vec, + /// The global swatches of the color group being rewritten. + group: Vec, keys: HashMap, hexes: HashMap, /// Set when a colour took a new colour of another model in its own model. @@ -273,14 +315,17 @@ struct ColorMap { } impl ColorMap { - /// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none). - fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option]) -> Self { - let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() }; + /// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none); `group`: the + /// global swatches of the color group being rewritten. + fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option], group: Vec) -> Self { + let mut out = + Self { rows: vec![], links: vec![], tos: vec![], group, keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() }; for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) { let Some(to) = r.new_color() else { continue }; let i = out.rows.len(); let colors: Vec = r.from.iter().map(Match::color).collect(); out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model)); + out.tos.push(to); out.links.push(links.get(n).cloned().flatten()); for m in &r.from { match m { @@ -307,11 +352,26 @@ impl ColorMap { /// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot /// swatch links its colours to it (at their tints; shades and other hues stay unlinked), a - /// process swatch gives its colour in its own model; other rows map the colour, which loses its - /// link when it changes. True when anything changed. + /// process swatch gives its colour in its own model; a color linked to a global swatch of the + /// rewritten group that takes the row's new color keeps its link (at its tint in the row) when + /// the row gives it the new color in that color's model; + /// other rows map the colour, which loses its link when it changes or when the group's rewrite + /// would change it. True when anything changed. fn map_link(&self, c: &mut Color, link: &mut Option, tint: &mut f32) -> bool { let Some(i) = self.row_of(*c) else { return false }; let (Some(row), Some(l)) = (self.rows.get(i), self.links.get(i)) else { return false }; + if l.is_none() + && let Some(g) = link.as_deref().and_then(|n| self.group.iter().find(|g| g.name == n)) + && self.tos.get(i) == Some(&g.color) + && !row.converts(*c) + && let Some(t) = row.tint(*c) + { + let t = t.clamp(0.0, 1.0); + let shown = g.shown.tinted(t); + let changed = *tint != t || *c != shown; + (*c, *tint) = (shown, t); + return changed; + } let new = match (l, row.tint(*c)) { (Some(l), Some(t)) if l.global => { let t = t.clamp(0.0, 1.0); @@ -328,7 +388,10 @@ impl ColorMap { row.map(*c) } }; - if new == *c { + // A color its row leaves as it is still loses its link to a swatch of the rewritten group + // when the rewrite would give it another color. + let rewritten = link.as_deref().and_then(|n| self.group.iter().find(|g| g.name == n)).is_some_and(|g| g.shown.tinted(*tint) != new); + if new == *c && !rewritten { return false; } *c = new; @@ -418,17 +481,31 @@ fn apply(s: &mut Session, p: &Value) -> Result { Some(Link { name, global: w.global, color }) }) .collect(); - let map = ColorMap::new(&rows, method, own_model, &links); + // The group's global swatches that its rewrite gives a color: art linked to one of them + // whose row's new color is that color stays linked. + let kept: Vec = group + .and_then(|g| d.swatch_groups.iter().find(|x| x.name == g)) + .map(|g| { + g.swatches + .iter() + .zip(&new) + .filter(|(w, _)| w.global && !w.is_reserved()) + .map(|(w, c)| GroupSwatch { name: w.name.clone(), color: *c, shown: d.linked_color(*c, w.spot) }) + .collect() + }) + .unwrap_or_default(); + let map = ColorMap::new(&rows, method, own_model, &links, kept); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; Ok((changed, edit, added, map.converted.get())) })?; + let mut out = json!({ "changed": changed, "swatches": added }); if let Some(e) = edit { e.defaults(s); + out["relinked"] = json!(e.relinked()); } - let mut out = json!({ "changed": changed, "swatches": added }); if converted { out["note"] = json!( "some colours kept their own colour model, so the new colour was converted into it and may look different; \ diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 7d54c9a44..03b632a26 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -264,6 +264,22 @@ impl FolderStore { builder.create(dir).map_err(|e| format!("{}: {e}", dir.display())) } + /// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix) + /// belongs to this account. Listing areas leaves links out, so a name another account had + /// linked to a folder of its own in a shared recovery folder looked free, and claiming it + /// wrote the copies through the link. + fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> { + let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + if !m.file_type().is_dir() { + return Err(format!("{}: not a folder of its own", dir.display())); + } + #[cfg(unix)] + if std::os::unix::fs::MetadataExt::uid(&m) != rustix::process::geteuid().as_raw() { + return Err(format!("{}: belongs to another account", dir.display())); + } + Ok(()) + } + /// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist. fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result, String> { let entries = match std::fs::read_dir(dir) { @@ -298,6 +314,7 @@ impl RecoveryStore for FolderStore { let f = self.file(name)?; if let Some(dir) = f.parent() { Self::create_area(dir)?; + Self::own_area(dir)?; } vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display())) } @@ -317,6 +334,7 @@ impl RecoveryStore for FolderStore { fn lock(&self, area: &str) -> std::result::Result { let dir = self.area(area)?; Self::create_area(&dir)?; + Self::own_area(&dir)?; let path = dir.join(LOCK_FILE); let file = std::fs::OpenOptions::new() .read(true) @@ -571,17 +589,35 @@ static WRITES: Mutex<()> = Mutex::new(()); type Named = (Weak>, String); /// The entry names of image bytes already hashed ([`blob_name`]), so an image is hashed once, -/// not at every interval. Bytes behind a live `Weak` never change (`Arc::make_mut` copies them). -static NAMES: Mutex> = Mutex::new(Vec::new()); +/// not at every interval, keyed by the bytes' address. Bytes behind a live `Weak` never change +/// (`Arc::make_mut` copies them). +static NAMES: Mutex = Mutex::new(Names { by_address: None, live_after_sweep: 0 }); + +struct Names { + by_address: Option>, + /// How many entries were live at the last sweep of dead ones: the next sweep waits until there + /// are twice as many, so sweeping costs a constant per name, not a pass over all of them. + live_after_sweep: usize, +} /// The name of the entry holding `bytes`: from their content (128 bits of hash and the length), so /// identical images share one entry and one written before isn't written again. fn blob_name(bytes: &Arc>) -> String { - let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner()); - names.retain(|(w, _)| w.strong_count() > 0); - if let Some((_, name)) = names.iter().find(|(w, _)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes))) { + let mut guard = NAMES.lock().unwrap_or_else(|e| e.into_inner()); + let Names { by_address, live_after_sweep } = &mut *guard; + let names = by_address.get_or_insert_with(Default::default); + let address = Arc::as_ptr(bytes) as usize; + // A dead entry at this address named other bytes that were freed there before. + if let Some((w, name)) = names.get(&address) + && w.strong_count() > 0 + && std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes)) + { return name.clone(); } + if names.len() >= (*live_after_sweep * 2).max(64) { + names.retain(|_, (w, _)| w.strong_count() > 0); + *live_after_sweep = names.len(); + } let hash = |seed: u8| { let mut h = std::collections::hash_map::DefaultHasher::new(); seed.hash(&mut h); @@ -589,7 +625,7 @@ fn blob_name(bytes: &Arc>) -> String { h.finish() }; let name = format!("img-{:016x}{:016x}-{}{BLOB_EXT}", hash(1), hash(2), bytes.len()); - names.push((Arc::downgrade(bytes), name.clone())); + names.insert(address, (Arc::downgrade(bytes), name.clone())); name } @@ -780,6 +816,7 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec Vec { vec![ @@ -103,7 +104,7 @@ pub fn specs() -> Vec { "Fill Color", ["Select", "Same"], None, - "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent)", + "{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; @@ -115,7 +116,7 @@ pub fn specs() -> Vec { "Stroke Color", ["Select", "Same"], None, - "{} the objects stroked with the first selected object's stroke colour (tints as select.same.fillColor)", + "{} the objects stroked with the first selected object's stroke colour (tints and compound paths as select.same.fillColor)", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; @@ -132,7 +133,7 @@ pub fn specs() -> Vec { "Fill & Stroke", ["Select", "Same"], None, - "{} the objects with the first selected object's fill and stroke colours (tints as select.same.fillColor)", + "{} the objects with the first selected object's fill and stroke colours (tints and compound paths as select.same.fillColor)", has_selection, |s, _| { let tint = s.prefs.select_same_tint_percent; @@ -179,7 +180,7 @@ pub fn specs() -> Vec { "Graphic Style", ["Select", "Same"], None, - "{} the objects linked to the graphic style the first selected object is linked to", + "{} the objects linked to the graphic style the first selected object is linked to (compound paths as select.same.fillColor)", has_selection, same_graphic_style ), @@ -188,7 +189,7 @@ pub fn specs() -> Vec { "Appearance Attribute", ["Select", "Same"], None, - "{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill", + "{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill (compound paths as select.same.fillColor)", has_selection, same_attribute ), @@ -199,7 +200,7 @@ pub fn specs() -> Vec { /// The objects Select All takes: those of the isolated container, else of every layer (looking /// through sublayers, which are not objects). In isolation mode there are none while the isolated /// container, or a group or layer it is in, is hidden, locked or a template layer. -fn selectable(d: &Document, iso: Option) -> Vec { +pub(crate) fn selectable(d: &Document, iso: Option) -> Vec { match iso.and_then(|i| d.node(i)) { Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => { // A single object isolated is all there is to select. @@ -663,15 +664,32 @@ fn paint_reported(d: &Document, n: &Node) -> bool { compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound)) } +/// The object Select > Same compares the others with, and whether isolation mode is inside a +/// compound path. The object is the first selected one, or its compound path when it is a member +/// of one; in isolation mode inside a compound path it is the member path itself. +fn same_reference<'a>(st: &'a DocState, cmd: &str) -> Result<(&'a Node, bool)> { + let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. }); + let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound))); + let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; + let refn = match st.doc.parent_of(first.id).and_then(|p| st.doc.node(p)) { + Some(p) if !members && compound(p) => p, + _ => first, + }; + Ok((refn, members)) +} + +/// Select > Same: the objects `eq` matches with the object [`same_reference`] returns, among those +/// [`paint_reported`] accepts. A compound path matches as a whole. In isolation mode inside a +/// compound path, its member paths are matched instead. fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result { - let st = s.doc()?; - let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?; - select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn)) + let (refn, members) = same_reference(s.doc()?, cmd)?; + let refn = refn.clone(); + select_where(s, cmd, &json!({}), |d, n| (if members { !n.is_container() } else { paint_reported(d, n) }) && eq(n, &refn)) } /// Select the objects [`walk_selectable`] visits that `f` accepts (not looking inside accepted /// ones); Select > Reselect repeats `cmd` with `p`. -fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result { +pub(crate) fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result { let st = s.doc()?; let mut ids = vec![]; walk_selectable(&st.doc, st.isolation, |n| { @@ -683,20 +701,17 @@ fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &No }); let from = st.selection.objects.clone(); s.select(|_, sel| sel.set(ids.iter().copied()))?; - s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone(), from)); - Ok(json!({ "count": ids.len() })) + let st = s.doc_mut()?; + st.last_selection_cmd = Some((cmd.to_string(), p.clone(), from)); + // The objects selected, after Lock Guides has left out guide paths. + Ok(json!({ "count": st.selection.objects.len() })) } fn same_graphic_style(s: &mut Session, _: &Value) -> Result { let cmd = "select.same.graphicStyle"; let st = s.doc()?; - let id = st - .selection - .objects - .first() - .and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?)) - .map(|g| g.id) - .ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; + let (refn, _) = same_reference(st, cmd)?; + let id = super::style::linked_style(&st.doc, refn).map(|g| g.id).ok_or_else(|| bad(cmd, "the selection has no graphic style"))?; select_where(s, cmd, &json!({}), |d, n| n.extras().graphic_style == Some(id) && super::style::linked_style(d, n).is_some()) } @@ -709,7 +724,7 @@ enum Attribute { fn same_attribute(s: &mut Session, p: &Value) -> Result { let cmd = "select.same.appearanceAttribute"; let st = s.doc()?; - let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; + let (refn, _) = same_reference(st, cmd)?; let ap = &refn.appearance; let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item()); let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) { @@ -750,6 +765,7 @@ fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result { } true }); - s.select(|_, sel| sel.set(ids.iter().copied()))?; - Ok(json!({ "count": ids.len() })) + s.select(|_, sel| sel.set(ids))?; + // The objects selected, after Lock Guides has left out guide paths. + Ok(json!({ "count": s.doc()?.selection.objects.len() })) } diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 503229f94..a65ab3e47 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -9,7 +9,7 @@ use vectorcraft_doc::shaper::{EdgeEdit, FILL_PREFIX, FaceEdit, FaceSelector, SOU use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _}; use vectorcraft_pathops as po; -use vectorcraft_tools::builder::{self as b, BuilderMap, Hit}; +use vectorcraft_tools::builder::{self as b, BuilderMap}; const MAX_SOURCES: usize = 64; const MAX_POINTS: usize = 4096; @@ -617,8 +617,9 @@ fn scribble(s: &mut Session, p: &Value) -> Result { let edits = face_edits(&map, &src, &spec); let filled = |i: usize| edits.get(&i).is_none_or(|e| !e.erase) && map.regions.get(i).and_then(|r| fill_source(&src, &r.0)).is_some(); let components = visible_components(&map, &src, &edits); + // Every face the scribble crosses, however narrow. let faces: std::collections::BTreeSet = - points.iter().filter_map(|p| map.region_at(*p)).filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect(); + map.hits_along(&pts, None).regions.into_iter().filter(|i| filled(*i)).filter_map(|i| components.get(i).copied()).collect(); let touched: Vec = components.iter().enumerate().filter(|(_, c)| faces.contains(c)).map(|(i, _)| i).collect(); let first = points.first().copied().unwrap_or(Point::ZERO); let last = points.last().copied().unwrap_or(first); @@ -642,19 +643,12 @@ fn scribble(s: &mut Session, p: &Value) -> Result { } let mut strokes = 0; if touched.is_empty() { - let mut piece_ids = vec![]; + // Every stroke the scribble crosses or passes within `tolerance` of, however small that is. + let mut hits = map.hits_along(&pts, Some(tolerance)); for point in &points { - let index = match map.hit(*point, Some(tolerance)) { - Some(Hit::Line(i)) => Some(map.edges.len() + i), - Some(Hit::Edge(i)) => Some(i), - _ => None, - }; - if let Some(i) = index - && !piece_ids.contains(&i) - { - piece_ids.push(i); - } + hits.add(map.hit(*point, Some(tolerance))); } + let piece_ids = hits.edges.iter().copied().chain(hits.lines.iter().map(|i| map.edges.len() + i)); for i in piece_ids { if let Some(e) = pieces(&map).get(i).and_then(|p| edge_selector(&map, &src, p)) { spec.edges.push(e); diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index b75680e1a..57e3b30bb 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -109,13 +109,21 @@ pub fn specs() -> Vec { has_doc, clip_to_artboard ), - cmd!(query "view.slices.hide", "Hide Slices", ["View"], None, "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state) → {hidden}", has_doc, hide), + cmd!( + query "view.slices.hide", + "Hide Slices", + ["View"], + None, + "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state); hiding deselects the slices in every open document, and showing them again leaves them deselected and does not restore the selection that was cleared; while they are hidden no command selects one (an object that is an object slice can still be selected as art) → {hidden}", + has_doc, + hide + ), cmd!( query "view.slices.lock", "Lock Slices", ["View"], None, - "{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool → {locked}", + "{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool; locking deselects the slices in every open document, and unlocking leaves them deselected and does not restore the selection that was cleared; while it is on no command selects one (an object that is an object slice can still be selected as art) → {locked}", has_doc, lock ), @@ -124,7 +132,7 @@ pub fn specs() -> Vec { "List Slices", [], None, - "{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected}], clipToArtboard, hidden, locked}", + "{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected (selected with the Slice Selection tool or a slice command, or an object slice whose object is selected)}], clipToArtboard, hidden, locked}", has_doc, list ), @@ -589,18 +597,35 @@ fn clip_to_artboard(s: &mut Session, p: &Value) -> Result { fn hide(s: &mut Session, p: &Value) -> Result { let v = p.get("hidden").and_then(Value::as_bool).unwrap_or(!s.menu.slices_hidden); s.menu.slices_hidden = v; + if v { + deselect_in_every_document(s); + } Ok(json!({ "hidden": v })) } fn lock(s: &mut Session, p: &Value) -> Result { let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.slices_locked); s.menu.slices_locked = v; + if v { + deselect_in_every_document(s); + } Ok(json!({ "locked": v })) } +/// Locked or hidden slices can't stay selected, in any open document ([`crate::deselect_hidden`]). +fn deselect_in_every_document(s: &mut Session) { + for st in &mut s.docs { + if crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) { + st.revision += 1; + } + } +} + fn list(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let d = &st.doc; + // What the Object → Slice commands act on: the selected objects that are object slices too. + let selected = selected_slices(d, &st.selection); let slices: Vec = d .slice_layout() .iter() @@ -609,7 +634,7 @@ fn list(s: &mut Session, _: &Value) -> Result { json!({ "number": a.number, "id": a.id.map(|i| i.0), "source": a.source.key(), "name": d.slice_name(a), "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height(), - "options": options_json(&o), "selected": a.id.is_some_and(|id| st.selection.slices.contains(&id)), + "options": options_json(&o), "selected": a.id.is_some_and(|id| selected.contains(&id)), }) }) .collect(); @@ -618,9 +643,9 @@ fn list(s: &mut Session, _: &Value) -> Result { fn select_all(s: &mut Session, _: &Value) -> Result { let ids = s.doc()?.doc.slice_ids(); - let n = ids.len(); s.select(|_, sel| sel.set_slices(ids))?; - Ok(json!({ "count": n })) + // None while the slices are locked or hidden. + Ok(json!({ "count": s.doc()?.selection.slices.len() })) } // ---------- the Slice and Slice Selection tools ---------- diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 2df742a97..8c78d49e0 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -830,6 +830,11 @@ impl GroupEdit { pub(super) fn defaults(&self, s: &mut Session) { self.relinks.iter().for_each(|r| r.defaults(s)); } + + /// How many paints linked to the group's swatches the rewrite changed. + pub(super) fn relinked(&self) -> usize { + self.relinked + } } /// Rewrite colour group `group` with `colors`: its swatches take them in order (keeping their diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 0c29ac4e7..745086fa5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -892,6 +892,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { ruby: Vec::new(), para: super::create::new_type_para(), paras: Vec::new(), + first_para_continues: false, area: Default::default(), path_effect: Default::default(), path_align: Default::default(), @@ -900,6 +901,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { optical_margin: false, system_layout: false, fractional_widths: true, + list_count: 0, cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1140,6 +1142,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { ruby: t.ruby.clone(), para: Default::default(), paras: Vec::new(), + first_para_continues: false, area: Default::default(), path_effect: Default::default(), path_align: Default::default(), @@ -1148,6 +1151,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { optical_margin: false, system_layout: t.system_layout, fractional_widths: t.fractional_widths, + list_count: 0, cached_bounds: None, cached_baselines: Vec::new(), }; diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index e1583e129..5efdce54c 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -153,11 +153,20 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { next.runs = part.runs; next.set_paragraph_styles(part.paras); let optical = optical.unwrap_or(t.optical_margin); - if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin { + let continues = part.continues; + if next.runs != t.runs + || next.para != t.para + || next.paras != t.paras + || optical != t.optical_margin + || part.list_count != t.list_count + || continues != t.first_para_continues + { t.runs = next.runs; t.para = next.para; t.paras = next.paras; t.optical_margin = optical; + t.list_count = part.list_count; + t.first_para_continues = continues; refresh_bounds(t); } } @@ -169,6 +178,36 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { doc.text_threads = kept; } +/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count +/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a +/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of +/// its thread) gets 0 and is laid out again. +pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc) { + let mut stale = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind + && t.list_count != 0 + { + stale.push(n.id); + } + }); + if stale.is_empty() { + return; + } + let threaded: std::collections::HashSet = doc.text_threads.iter().flatten().copied().filter(|id| text_of(doc, *id).is_some()).collect(); + stale.retain(|id| !threaded.contains(id)); + if stale.is_empty() { + return; + } + let doc = std::sync::Arc::make_mut(doc); + for id in stale { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.list_count = 0; + refresh_bounds(t); + } + } +} + /// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes: /// that of the first frame whose setting changed between `before` and `doc`, else the first frame's. fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option { @@ -194,6 +233,25 @@ pub(crate) fn share_optical_margin(doc: &mut Document) { } } +/// Mark the frames of each thread whose first paragraph continues from the frame before +/// ([`TextObject::first_para_continues`]), without re-flowing the story: for a document restored +/// from a file as it was saved. +pub(crate) fn mark_continued(doc: &mut Document) { + for thread in doc.text_threads.clone() { + let live: Vec = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect(); + let frames: Vec<&TextObject> = live.iter().filter_map(|id| text_of(doc, *id)).collect(); + let marks = vectorcraft_text::thread::continued(&frames); + for (id, on) in live.iter().zip(marks) { + if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) + && t.first_para_continues != on + { + t.first_para_continues = on; + refresh_bounds(t); + } + } + } +} + /// `ids` followed by the other frames of the threads they are in, each once, in thread order. pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec { let mut out = ids.to_vec(); @@ -207,6 +265,110 @@ pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec { out } +/// Every frame of `d`'s threads with its type, for [`leave_threads`] after an edit that may turn +/// some of them into other art. +pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> { + d.text_threads.iter().flatten().filter_map(|id| Some((*id, text_of(d, *id)?.clone()))).collect() +} + +/// Update the threads for each frame of `was` (from [`frames`] before the edit) that `d` no longer +/// holds as area type or type on a path under its id. When the art now at that id holds the frame's +/// type under a new id (Expand Appearance groups type with its shadows, outer glows or Crop Marks), +/// that type takes the frame's place in its thread. Otherwise (Create Outlines and Expand Appearance +/// made outlines of it) the frame leaves its thread with the text it shows, and the other frames keep +/// the text they hold. When a thread's last frame leaves, the text it has no room for goes after the +/// text of the last frame that stays. A thread left with one frame is no longer a thread. Each frame +/// that stays continues a paragraph ([`TextObject::first_para_continues`]) only when it is still +/// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out +/// its first line again, so its thread re-flows after the edit. +pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) { + let gone: Vec<(NodeId, &TextObject, Option)> = + was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect(); + if gone.is_empty() { + return; + } + let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id); + let mut stayed = vec![]; + for i in 0..d.text_threads.len() { + let thread = d.text_threads[i].clone(); + if !thread.iter().any(|id| find(id).is_some()) { + continue; + } + let keep: Vec = thread + .iter() + .filter_map(|id| match find(id) { + Some((_, _, kept)) => *kept, + None => Some(*id), + }) + .collect(); + if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) { + take_overflow(d, *stay, last); + } + stayed.extend(keep.iter().copied()); + d.text_threads[i] = keep; + } + d.text_threads.retain(|t| t.len() >= 2); + let marks: Vec<(NodeId, bool)> = stayed + .iter() + .map(|id| { + let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| { + let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect(); + let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect(); + live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id) + }); + (*id, on) + }) + .collect(); + for (id, on) in marks { + if text_of(d, id).is_some_and(|t| t.first_para_continues != on) + && let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) + { + t.first_para_continues = on; + refresh_bounds(t); + } + } +} + +/// The only area type or type on a path in the art at `id`. +fn kept_type(d: &Document, id: NodeId) -> Option { + let mut found = vec![]; + d.node(id)?.walk(&mut |n| { + if matches!(&n.kind, NodeKind::Text(t) if !matches!(t.kind, TextKind::Point)) { + found.push(n.id); + } + }); + match found[..] { + [one] => Some(one), + _ => None, + } +} + +/// Put the text that frame `from` holds beyond what it shows after the text of frame `to`. +fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) { + let db = vectorcraft_text::FontDb::global(); + // `from`'s text split as if it flowed on into a second frame: the second part is what `from` + // has no room for. Its share is measured as it is laid out, continuing a paragraph or not and + // with its numbered list paragraphs counted on from the frames before it. + let parts = vectorcraft_text::thread::distribute_continuing( + db, + &[from, from], + &from.runs, + &from.paragraph_styles(), + from.first_para_continues, + from.list_count, + ); + let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return }; + let mut tail = from.clone(); + tail.runs = rest.runs.clone(); + tail.set_paragraph_styles(rest.paras.clone()); + if let Some(NodeKind::Text(t)) = d.node_mut(to).map(|n| &mut n.kind) { + let head = (**t).clone(); + t.runs = vectorcraft_text::thread::story(&[&head, &tail]); + t.set_paragraph_styles(vectorcraft_text::thread::story_paras(&[&head, &tail], &[])); + refresh_bounds(t); + } +} + /// A closed path converted to an empty area-type frame with `style`. fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle, system_layout: bool) -> Option { let NodeKind::Path { path, .. } = &n.kind else { return None }; @@ -387,6 +549,7 @@ fn release(s: &mut Session, _: &Value) -> Result { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { t.runs = vec![TextRun { text: String::new(), style: style.clone(), inline: None }]; t.set_all_paras(first_para.clone()); + t.first_para_continues = false; refresh_bounds(t); } } @@ -406,6 +569,9 @@ fn release(s: &mut Session, _: &Value) -> Result { vectorcraft_text::edit::normalize(&mut story); t.runs = story; t.set_paragraph_styles(paras); + // It starts the story, also when it is left alone and so out of a thread. + t.first_para_continues = false; + refresh_bounds(t); } let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0); d.text_threads[i] = keep; @@ -419,7 +585,18 @@ fn release(s: &mut Session, _: &Value) -> Result { fn remove(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; s.edit("Remove Threading", |d, _| { - d.text_threads.retain(|t| !t.iter().any(|i| ids.contains(i))); + let (gone, kept): (Vec>, Vec>) = + std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i))); + d.text_threads = kept; + // Each frame's first paragraph now starts in it. + for id in gone.iter().flatten() { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) + && t.first_para_continues + { + t.first_para_continues = false; + refresh_bounds(t); + } + } Ok(()) })?; ok() @@ -815,6 +992,414 @@ mod tests { 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]) + } + + /// 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); + } + + /// 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) { @@ -923,4 +1508,178 @@ mod tests { 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]); + } + + /// 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 b8314b1dc..bc01e71f7 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Create Outlines", ["Type"], Some("Cmd+Shift+O"), - "{} convert the selected text to groups of compound paths (one per glyph) → {ids}", + "{} convert the selected text to groups of compound paths (one per glyph); a frame of threaded text leaves its thread with the text it shows → {ids}", has_selection, create_outlines ), @@ -544,32 +544,53 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { } let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); let mut out = vec![]; + let threaded = super::threads::frames(d); for tid in texts { let Some(n) = d.node(tid).cloned() else { continue }; let NodeKind::Text(t) = &n.kind else { continue }; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let mut children = vec![]; + // Where each run's glyphs end among `children`: the canvas paints a run's ruby with the + // run, before the runs after it, so its outlines go there too. + let mut run_end: std::collections::BTreeMap = Default::default(); for (gi, g) in lay.glyphs.iter().enumerate() { - // Inline graphics become instances of their symbols, in place. - if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi) - && let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) - { - let id = d.alloc_id(); - let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol); - children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf }))); - continue; - } - // Colour bitmap glyphs (emoji) become embedded images, in place, their pixels taken - // into the document: glyphs that show the same image share them. - if let Some(image) = g.placed_image_node(t.xf) { - children.push(Arc::new(d.reid(&image))); - continue; - } - // COLR glyphs become groups of their layers, in place, with ids of the document's. - if let Some(art) = g.placed_colour(t.xf) { - children.push(Arc::new(d.reid(&art))); - continue; + 'glyph: { + // Inline graphics become instances of their symbols, in place. + if let Some(ig) = lay.inlines.iter().find(|i| i.glyph == gi) + && let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) + { + let id = d.alloc_id(); + let xf = t.xf * ig.xf * d.symbol_natural_xf(&art.symbol); + children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf }))); + break 'glyph; + } + // Colour bitmap glyphs (emoji) become embedded images, in place, their pixels + // taken into the document: glyphs that show the same image share them. + if let Some(image) = g.placed_image_node(t.xf) { + children.push(Arc::new(d.reid(&image))); + break 'glyph; + } + // COLR glyphs become groups of their layers, in place, with ids of the document's. + if let Some(art) = g.placed_colour(t.xf) { + children.push(Arc::new(d.reid(&art))); + break 'glyph; + } + let path = PathData::from_bezpath(&g.outline).transformed(t.xf); + if path.is_empty() { + break 'glyph; + } + let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); + let mut node = shape_node(d, path, None); + node.appearance = st.appearance().into(); + children.push(Arc::new(node)); } + run_end.insert(g.run, children.len()); + } + // Ruby (furigana) glyphs, where the layout put them, in their base text's run's paint, + // right after that run's glyphs, as the canvas draws them (colour glyphs in ruby draw + // nothing there either). + let mut ruby: Vec<(usize, Arc)> = vec![]; + for g in &lay.ruby { let path = PathData::from_bezpath(&g.outline).transformed(t.xf); if path.is_empty() { continue; @@ -577,8 +598,21 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); let mut node = shape_node(d, path, None); node.appearance = st.appearance().into(); - children.push(Arc::new(node)); + // After the glyphs of its run and of the runs before it. + let at = run_end.range(..=g.run).map(|(_, end)| *end).max().unwrap_or(0); + ruby.push((at, Arc::new(node))); } + ruby.sort_by_key(|(at, _)| *at); + let mut ruby = ruby.into_iter().peekable(); + let mut placed = Vec::with_capacity(children.len() + ruby.len()); + for (i, c) in children.into_iter().enumerate() { + while let Some((_, r)) = ruby.next_if(|(at, _)| *at == i) { + placed.push(r); + } + placed.push(c); + } + placed.extend(ruby.map(|(_, r)| r)); + let mut children = placed; // Underline and strikethrough bars become paths of their run's paint too (#847). for (run, bar) in vectorcraft_text::decorations(&lay, vectorcraft_text::FontDb::global(), t) { let st = t.runs.get(run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); @@ -602,6 +636,8 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { new_sel.push(gid); } } + // Outlined frames of threaded text leave their threads with the text they show. + super::threads::leave_threads(d, &threaded); sel.set(new_sel); Ok(out) })?; @@ -1501,6 +1537,79 @@ mod area_tests { 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)); + } + /// 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() { @@ -2176,6 +2285,72 @@ mod ruby_tests { assert!(text_of(&s, id).ruby.is_empty()); } + /// Create Outlines keeps the ruby: one path per inked glyph of the base text and of the + /// reading, each where the layout (and so the canvas) puts it. + #[test] + fn create_outlines_keeps_the_ruby_where_it_is_laid_out() { + use kurbo::Shape; + let (mut s, id) = session_with_text(); + s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap(); + let t = text_of(&s, id).clone(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let inked = |gs: &[vectorcraft_text::PositionedGlyph]| -> Vec { + gs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| t.xf.transform_rect_bbox(g.outline.bounding_box())).collect() + }; + let (base, ruby) = (inked(&lay.glyphs), inked(&lay.ruby)); + assert_eq!(ruby.len(), 3, "the reading's three kana have ink"); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let paths: Vec = d.node(NodeId(g)).unwrap().children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect(); + assert_eq!(paths.len(), base.len() + ruby.len(), "a path per glyph, the reading's included: {paths:?}"); + let near = |a: &kurbo::Rect, b: &kurbo::Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.05); + for r in &ruby { + assert!(paths.iter().any(|p| near(p, r)), "the kana at {r:?} among {paths:?}"); + } + // Undo brings the type back with its ruby. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_of(&s, id).ruby.len(), 1); + } + + /// The outlines stack as the canvas paints type, run by run (`render`'s `TextGeom::run`): a + /// run's reading goes right after its base glyphs, below the glyphs of the runs after it. Here + /// the red 漢's wide reading hangs over the big blue 字. + #[test] + fn create_outlines_stacks_each_reading_with_its_run() { + use kurbo::Shape; + let (mut s, id) = session_with_text(); + s.execute("text.setText", &json!({"id": id, "text": "漢字"})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "fill": "#ff0000"})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "size": 60, "fill": "#0000ff"})).unwrap(); + s.execute("type.ruby", &json!({"text": "かんじかんじ", "start": 0, "end": 3, "size": 100})).unwrap(); + let t = text_of(&s, id).clone(); + assert_eq!(t.runs.len(), 2, "{:?}", t.runs); + let (red, blue) = (t.runs[0].style.fill.clone(), t.runs[1].style.fill.clone()); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let ink = |gs: &mut dyn Iterator| { + gs.filter(|g| !g.outline.elements().is_empty()).map(|g| g.outline.bounding_box()).reduce(|a, b| a.union(b)).unwrap() + }; + let (reading, big) = (ink(&mut lay.ruby.iter()), ink(&mut lay.glyphs.iter().filter(|g| g.run == 1))); + assert!(reading.intersect(big).area() > 0.0, "the reading {reading:?} hangs over 字 {big:?}"); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let paints: Vec = d + .node(NodeId(g)) + .unwrap() + .children() + .unwrap() + .iter() + .map(|c| match c.appearance.fill().map(|f| &f.paint) { + Some(p) if *p == red => "red".into(), + Some(p) if *p == blue => "blue".into(), + p => format!("{p:?}"), + }) + .collect(); + // 漢 and its six kana, then 字 over them. + let want: Vec = ["red"; 7].into_iter().chain(["blue"]).map(String::from).collect(); + assert_eq!(paints, want); + } + /// Ruby is written to SVG and PDF with its text: SVG outlines the type where ruby is present /// (a reader's `` can't place the reading), PDF differs when the ruby is there. #[test] diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index a61707b00..74d94ed80 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -329,6 +329,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let TextKind::Area { frame } = &t.kind else { continue }; let fb = frame.bounds().unwrap_or_default(); + // Point type is in no thread: its first paragraph starts in it. + t.first_para_continues = false; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let plain = t.plain_text(); let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8); diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 9864fc7d5..0106e9895 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -469,6 +469,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result { fn apply_dataset(s: &mut Session, name: &str) -> Result { const C: &str = "dataset.select"; + let doc_path = s.doc()?.path.clone(); let (applied, skipped, mut typed, bound) = s.edit("Apply Data Set", |d, sel| { let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?; // One object may be driven by several variables; walk them in definition order, so two @@ -513,7 +514,7 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result { true }), // Swap the linked file; keep the object's bounds. A missing path skips. - (VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path).is_ok(), + (VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path, doc_path.as_deref()).is_ok(), (VariableKind::Graph, DataValue::Graph(csv)) => super::graph::apply_csv(d, id, csv).is_ok(), _ => false, }; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d0a0baadd..53db233aa 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -2,7 +2,8 @@ use serde_json::{Value, json}; use vectorcraft_color::BlendMode; -use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, WidthProfile}; +use vectorcraft_geom::Rect; use crate::Session; @@ -265,6 +266,8 @@ fn document_base(s: &Session) -> Value { json!({ "uid": st.uid, "title": st.title(), + // The File Info title (`file.info`), where `title` comes from the file name once the document has one. + "fileInfoTitle": d.title, "path": st.path, "dirty": st.is_dirty(), "revision": st.revision, @@ -296,20 +299,87 @@ fn document_base(s: &Session) -> Value { /// What `document.find` matches: every given filter must hit (AND). All matching is /// case-insensitive; `name` and `text` are substrings, `kind` is the exact panel label -/// (`Layer`, `Group`, `Path`, `Type`, `Image`, ...). +/// (`Layer`, `Group`, `Path`, `Type`, `Image`, ...). The geometric filters read an object's +/// geometric bounds as `document.inspect` reports them; layers and sublayers fail them, and so +/// does a node without bounds (an empty group); `anchors` counts a path's anchor points, summed +/// over a compound path's members, and a node that is neither fails it. +#[derive(Default)] pub struct FindFilter { pub name: Option, pub kind: Option, pub text: Option, + /// The bounds lie inside this rectangle, edges included. + pub within: Option, + /// The bounds touch or overlap this rectangle. + pub intersects: Option, + /// The bounds' width. + pub width: FindRange, + /// The bounds' height. + pub height: FindRange, + /// The smaller of the bounds' width and height. + pub thickness: FindRange, + /// The bounds' width times their height. + pub area: FindRange, + /// The anchor points of a path, or of a compound path's members together. + pub anchors: FindRange, +} + +/// Inclusive limits on one measure of a [`FindFilter`]; neither given matches everything. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FindRange { + pub min: Option, + pub max: Option, +} + +impl FindRange { + pub fn is_empty(&self) -> bool { + self.min.is_none() && self.max.is_none() + } + + fn hits(&self, v: f64) -> bool { + self.min.is_none_or(|m| v >= m) && self.max.is_none_or(|m| v <= m) + } } impl FindFilter { /// True when no effective filter was given (empty strings count as absent). pub fn is_empty(&self) -> bool { [self.name.as_ref(), self.kind.as_ref(), self.text.as_ref()].iter().all(|f| f.is_none()) + && self.within.is_none() + && self.intersects.is_none() + && self.ranges().iter().all(|r| r.is_empty()) + && self.anchors.is_empty() } - fn hits(&self, n: &Node) -> bool { + /// The limits read from the bounds. + fn ranges(&self) -> [FindRange; 4] { + [self.width, self.height, self.thickness, self.area] + } + + fn hits(&self, n: &Node, symbols: &SymbolBoxes) -> bool { + if self.within.is_some() || self.intersects.is_some() || self.ranges().iter().any(|r| !r.is_empty()) { + if matches!(n.kind, NodeKind::Layer { .. }) { + return false; + } + let Some(b) = n.geometric_bounds_with(symbols) else { return false }; + if self.within.is_some_and(|r| b.x0 < r.x0 || b.y0 < r.y0 || b.x1 > r.x1 || b.y1 > r.y1) { + return false; + } + if self.intersects.is_some_and(|r| b.x0 > r.x1 || b.x1 < r.x0 || b.y0 > r.y1 || b.y1 < r.y0) { + return false; + } + let (w, h) = (b.width(), b.height()); + if !(self.width.hits(w) && self.height.hits(h) && self.thickness.hits(w.min(h)) && self.area.hits(w * h)) { + return false; + } + } + if !self.anchors.is_empty() && !anchor_count(n).is_some_and(|c| self.anchors.hits(c as f64)) { + return false; + } + self.hits_text(n) + } + + fn hits_text(&self, n: &Node) -> bool { if let Some(needle) = &self.name && !n.display_name().to_lowercase().contains(needle) { @@ -330,31 +400,47 @@ impl FindFilter { } } +/// The anchor points of a path, or of a compound path's members together; `None` for any other +/// node. +fn anchor_count(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.anchor_count()), + NodeKind::Compound { children, .. } => Some(children.iter().filter_map(|c| anchor_count(c)).sum()), + _ => None, + } +} + /// Search the whole layer tree, top of the stack first like the Layers panel. /// Returns at most `limit` hits as `{id, name, kind, path}` (`path` is the ancestor -/// chain, layer first) plus the total hit count, so a capped reply stays explicit. -pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec, u64) { +/// chain, layer first) plus the ids of every hit, so a capped reply stays explicit. +pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec, Vec) { use std::sync::Arc; - let mut out = Vec::new(); - let mut total = 0u64; - let mut ancestors: Vec = Vec::new(); - fn walk(nodes: &[Arc], ancestors: &mut Vec, filter: &FindFilter, limit: u64, out: &mut Vec, total: &mut u64) { + struct Walk<'a> { + filter: &'a FindFilter, + symbols: SymbolBoxes<'a>, + limit: u64, + ancestors: Vec, + out: Vec, + ids: Vec, + } + fn walk(nodes: &[Arc], w: &mut Walk) { for n in nodes.iter().rev() { - if filter.hits(n) { - *total += 1; - if (out.len() as u64) < limit { - out.push(json!({"id": n.id.0, "name": n.display_name(), "kind": n.kind_label(), "path": ancestors})); + if w.filter.hits(n, &w.symbols) { + w.ids.push(n.id); + if (w.out.len() as u64) < w.limit { + w.out.push(json!({"id": n.id.0, "name": n.display_name(), "kind": n.kind_label(), "path": w.ancestors})); } } if let Some(ch) = n.children() { - ancestors.push(n.id.0); - walk(ch, ancestors, filter, limit, out, total); - ancestors.pop(); + w.ancestors.push(n.id.0); + walk(ch, w); + w.ancestors.pop(); } } } - walk(&doc.layers, &mut ancestors, filter, limit, &mut out, &mut total); - (out, total) + let mut w = Walk { filter, symbols: doc.symbol_boxes(), limit, ancestors: Vec::new(), out: Vec::new(), ids: Vec::new() }; + walk(&doc.layers, &mut w); + (w.out, w.ids) } /// Cap names as `stroke.set` takes them. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bec7d9d9f..3ccedb7a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,7 +12,6 @@ pub mod guard; pub mod inspect; pub mod journal; pub mod link_watch; -pub mod namematch; pub mod steps; pub mod store; mod tooling; @@ -378,6 +377,10 @@ pub struct DocState { /// 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, @@ -401,6 +404,10 @@ pub struct DocState { /// 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); @@ -565,6 +572,7 @@ impl DocState { transparency_grid, format: "vectorcraft", save_options: Default::default(), + save_artboards: vec![], converted: false, view, recovered: false, @@ -574,6 +582,7 @@ impl DocState { 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 @@ -859,6 +868,19 @@ pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool { 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"); @@ -1405,6 +1427,13 @@ pub struct Session { /// The depth of the command whose values [`Session::note_journal`] keeps: 1 (the top-level /// command), or a step's while a batch runs it ([`Session::execute_step`]). note_depth: u32, + /// The running top-level query command changed the selection and is journaled like any other + /// command ([`Session::journal_query`]). + journal_query: bool, + /// From `edit.undoGroup.begin` to `edit.undoGroup.end`: the name of the undo step (`None`: after + /// its first edit). Each document opened or created meanwhile opens an undo group of its own + /// ([`Session::add_document`]). `None` otherwise. + pub(crate) explicit_undo_group: Option>, /// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an /// error brings back; `None` otherwise. pub(crate) batch_stash: Option>, @@ -1536,6 +1565,8 @@ impl Session { ui_language: None, journal_note: Default::default(), note_depth: 1, + journal_query: false, + explicit_undo_group: None, batch_stash: None, place_hand_off: None, queue_hand_off: None, @@ -1638,7 +1669,9 @@ impl Session { refresh_tree_text_bounds(tree); } } - /// Add a document and make it active. + /// 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). @@ -1653,6 +1686,11 @@ impl Session { 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 @@ -1677,6 +1715,17 @@ impl Session { 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(); @@ -1715,6 +1764,24 @@ impl Session { 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(|| EngineError::UnknownCommand(id.to_string()))?; @@ -1722,13 +1789,28 @@ impl Session { // 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_ids(spec.params, params) { - // Objects given as `ids` stand in for the selection: the command is enabled as it is - // with them selected (#1575). + } 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) @@ -1741,15 +1823,21 @@ impl Session { if self.depth == 0 { self.journal_note.clear(); self.note_depth = 1; + self.journal_query = false; self.batch_stash = None; } - // A selection-only command given `ids` 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")).flatten(); - let selected_before = select_first.and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer)); + // 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 { + if let Some(ids) = &select_first { s.execute("select.set", &serde_json::json!({ "ids": ids }))?; } let r = (spec.run)(s, params); @@ -1766,12 +1854,17 @@ impl Session { // 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 spec.journal && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { + 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)); } @@ -1797,6 +1890,14 @@ impl Session { } } + /// 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. @@ -1885,7 +1986,7 @@ impl Session { if !self.previewing && cmd::typing_in_progress(self) { cmd::settle_interaction(self)?; } - let guides_locked = self.menu.guides_locked; + let menu = self.menu.clone(); let st = self.doc_mut()?; let before = st.doc.clone(); let before_sel = st.selection.clone(); @@ -1911,6 +2012,9 @@ impl Session { 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)); @@ -1942,10 +2046,10 @@ impl Session { match result { Ok(v) => { st.selection.prune(&st.doc); - // An edit that selects what it unlocks, shows or pastes leaves locked guides out. - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + // 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)); @@ -1998,16 +2102,15 @@ impl Session { /// 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 guides_locked = self.menu.guides_locked; + 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), whatever selects them. - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + // 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); @@ -2069,6 +2172,7 @@ impl Session { /// 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 @@ -2081,6 +2185,9 @@ impl Session { 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. @@ -2156,13 +2263,11 @@ impl Session { pub fn cancel_interaction(&mut self) -> Result<()> { self.clear_deferred_overlay_cache(); self.pending_paint = None; - let guides_locked = self.menu.guides_locked; + let menu = self.menu.clone(); let st = self.doc_mut()?; st.undo_interaction(); - // Guides locked during the interaction stay deselected. - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + // Guides locked and slices locked or hidden during the interaction stay deselected. + deselect_hidden(&menu, &st.doc, &mut st.selection); Ok(()) } @@ -2189,6 +2294,20 @@ impl Session { } } + /// 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![], 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() @@ -2221,8 +2340,15 @@ impl Session { } /// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are - /// undone and dropped from the journal. + /// 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); + } + + /// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`. + fn close_undo_groups(&mut self, cancel: bool) { let mut journal: Vec<(usize, Option)> = vec![]; for st in &mut self.docs { let Some(g) = st.undo_group.take() else { continue }; @@ -2234,9 +2360,7 @@ impl Session { { st.doc = e.doc; st.selection = Arc::unwrap_or_clone(e.selection); - if self.menu.guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + deselect_hidden(&self.menu, &st.doc, &mut st.selection); st.sync_new_art(); cmd::globaledit::follow_history(st); st.revision += 1; @@ -2265,7 +2389,7 @@ impl Session { // The other documents' groups are set aside while `end_undo_group` closes this one's. let others: Vec<(u64, UndoGroup)> = self.docs.iter_mut().filter(|st| st.uid != uid).filter_map(|st| Some((st.uid, st.undo_group.take()?))).collect(); - self.end_undo_group(cancel); + self.close_undo_groups(cancel); for (other, g) in others { if let Some(st) = self.document_mut(other) { st.undo_group = Some(g); @@ -2298,7 +2422,7 @@ impl Session { self.end_document_undo_group(scope.uid, cancel); return; }; - let guides_locked = self.menu.guides_locked; + 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))); @@ -2317,9 +2441,7 @@ impl Session { st.doc = doc; st.selection = selection; cmd::globaledit::follow_history(st); - if guides_locked { - deselect_guides(&st.doc, &mut st.selection); - } + deselect_hidden(&menu, &st.doc, &mut st.selection); st.history.recost_last(&st.doc); st.revision += 1; self.journal.truncate(scope.journal); @@ -2739,8 +2861,16 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ ]; /// 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`]). +/// 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> { - let ids = cmd::ids_param(params, "ids").filter(|ids| !ids.is_empty())?; - cmd::params::takes_key(description, "ids").then_some(ids) + 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/namematch.rs b/crates/engine/src/namematch.rs deleted file mode 100644 index 497544578..000000000 --- a/crates/engine/src/namematch.rs +++ /dev/null @@ -1,328 +0,0 @@ -//! Matching a name another app's file writes (a shortcut set's entry name) against VectorCraft's -//! commands, tools and panels, by their ids and labels only. A match is proposed when one candidate -//! scores well and clearly ahead of the next; otherwise the best few are offered to choose from. - -use std::collections::HashMap; - -/// A command, tool or panel a name can stand for. -#[derive(Clone, Debug)] -pub struct Candidate { - /// `select.all`, `tool:rectangle` or `panel:layers`. - pub id: String, - /// Its labels: the English one, and the one in the interface language. - pub labels: Vec, -} - -/// What a name stands for, as far as [`propose`] can tell. -#[derive(Clone, Debug, PartialEq)] -pub enum Proposal { - /// The candidate at this index of the list, with its score (0 to 1). - Match(usize, f64), - /// The best candidates, best first, when none stands out. - Choice(Vec), -} - -/// The lowest score proposed. -const THRESHOLD: f64 = 0.6; -/// How far the best score must lead the next one to be proposed. -const MARGIN: f64 = 0.1; -/// The most candidates a [`Proposal::Choice`] lists. -const CHOICES: usize = 5; -/// The lowest score a [`Proposal::Choice`] lists. -const CHOICE_FLOOR: f64 = 0.3; - -/// `s` with only its letters and digits, lowercased. -fn folded(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() -} - -/// The words of `s`, lowercased: runs of letters and digits, split where a lowercase letter or a digit -/// meets an uppercase one (`selectAll`: `select`, `all`). -fn words(s: &str) -> Vec { - let mut out = vec![]; - let mut word = String::new(); - let mut prev_lower = false; - for c in s.chars() { - if !c.is_alphanumeric() { - if !word.is_empty() { - out.push(std::mem::take(&mut word)); - } - prev_lower = false; - continue; - } - if c.is_uppercase() && prev_lower && !word.is_empty() { - out.push(std::mem::take(&mut word)); - } - prev_lower = c.is_lowercase() || c.is_ascii_digit(); - word.extend(c.to_lowercase()); - } - if !word.is_empty() { - out.push(word); - } - out -} - -/// The words of a candidate's id: its segments after any `tool:` or `panel:`, split as [`words`]. -fn id_words(id: &str) -> Vec { - let id = id.strip_prefix("tool:").or_else(|| id.strip_prefix("panel:")).unwrap_or(id); - id.split('.').flat_map(words).collect() -} - -/// How many words `a` and `b` share, each word counted once, as a Dice coefficient (0 to 1). -fn word_overlap(a: &[String], b: &[String]) -> f64 { - if a.is_empty() || b.is_empty() { - return 0.0; - } - let mut rest: Vec<&String> = b.iter().collect(); - let mut shared = 0usize; - for w in a { - if let Some(i) = rest.iter().position(|x| *x == w) { - rest.swap_remove(i); - shared += 1; - } - } - (2 * shared) as f64 / (a.len() + b.len()) as f64 -} - -/// The pairs of neighboring characters in `s`. -fn pairs(s: &str) -> Vec<(char, char)> { - let c: Vec = s.chars().collect(); - c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() -} - -/// How many character pairs `a` and `b` (each sorted) share, as a Dice coefficient (0 to 1): for -/// names written without spaces. Linear in their lengths, so long names in a file stay quick. -fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 { - if a.is_empty() || b.is_empty() { - return 0.0; - } - let (mut i, mut j, mut shared) = (0usize, 0usize, 0usize); - while let (Some(p), Some(q)) = (a.get(i), b.get(j)) { - match p.cmp(q) { - std::cmp::Ordering::Less => i += 1, - std::cmp::Ordering::Greater => j += 1, - std::cmp::Ordering::Equal => { - shared += 1; - i += 1; - j += 1; - } - } - } - (2 * shared) as f64 / (a.len() + b.len()) as f64 -} - -/// A name or label as the scores compare it: its letters and digits (lowercased), its words and the -/// character pairs of the former (sorted). -struct Text { - folded: String, - words: Vec, - pairs: Vec<(char, char)>, -} - -impl Text { - fn of(s: &str) -> Self { - let folded = folded(s); - let mut pairs = pairs(&folded); - pairs.sort_unstable(); - Self { pairs, words: words(s), folded } - } -} - -/// A candidate as the scores compare it. -struct Prepared { - /// Its id's words, and those joined. - id_words: Vec, - id: String, - labels: Vec, -} - -impl Prepared { - fn of(c: &Candidate) -> Self { - let id_words = id_words(&c.id); - Self { id: id_words.concat(), id_words, labels: c.labels.iter().map(|l| Text::of(l)).collect() } - } - - /// See [`score`]. - fn score(&self, name: &Text) -> f64 { - if name.folded.is_empty() { - return 0.0; - } - if name.folded == self.id || self.labels.iter().any(|l| l.folded == name.folded) { - return 1.0; - } - let by_words = self.labels.iter().map(|l| word_overlap(&name.words, &l.words)).fold(word_overlap(&name.words, &self.id_words), f64::max); - let by_pairs = self.labels.iter().map(|l| pair_similarity(&name.pairs, &l.pairs)).fold(0.0, f64::max); - by_words.max(by_pairs * 0.9) - } -} - -/// How well `name` fits `candidate` (0 to 1): 1 when it equals a label or the id, its letters and -/// digits compared without case; else the best of word overlap and character-pair similarity, the -/// latter counted a little lower. -pub fn score(name: &str, candidate: &Candidate) -> f64 { - Prepared::of(candidate).score(&Text::of(name)) -} - -/// The candidate `name` stands for, when one scores at least 0.6 and 0.1 ahead of every other; else -/// the best five that score at least 0.3, to choose from. -pub fn propose(name: &str, candidates: &[Candidate]) -> Proposal { - Matcher::new(candidates).propose(name) -} - -/// [`propose`] for many names against the same candidates: each candidate's words and character -/// pairs are worked out once, and a name is scored only against the candidates that share a word or -/// a character pair with it (the others score 0). -pub struct Matcher { - prepared: Vec, - /// Candidates by their joined id words and their labels' letters and digits. - exact: HashMap>, - /// Candidates by a word of their id or labels. - by_word: HashMap>, - /// Candidates by a character pair of their labels. - by_pair: HashMap<(char, char), Vec>, -} - -impl Matcher { - pub fn new(candidates: &[Candidate]) -> Self { - let prepared: Vec = candidates.iter().map(Prepared::of).collect(); - let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new()); - fn add(map: &mut HashMap>, key: K, i: usize) { - let list = map.entry(key).or_default(); - if list.last() != Some(&i) { - list.push(i); - } - } - for (i, p) in prepared.iter().enumerate() { - add(&mut exact, p.id.clone(), i); - for l in &p.labels { - add(&mut exact, l.folded.clone(), i); - } - for w in p.id_words.iter().chain(p.labels.iter().flat_map(|l| &l.words)) { - add(&mut by_word, w.clone(), i); - } - for pair in p.labels.iter().flat_map(|l| &l.pairs) { - add(&mut by_pair, *pair, i); - } - } - Self { prepared, exact, by_word, by_pair } - } - - /// See [`propose`]. - pub fn propose(&self, name: &str) -> Proposal { - let text = Text::of(name); - let mut seen = vec![false; self.prepared.len()]; - let lists = self - .exact - .get(&text.folded) - .into_iter() - .chain(text.words.iter().filter_map(|w| self.by_word.get(w))) - // Each pair looked up once (they are sorted). - .chain(text.pairs.chunk_by(|a, b| a == b).filter_map(|c| self.by_pair.get(c.first()?))); - let mut scored: Vec<(usize, f64)> = vec![]; - for &i in lists.flatten() { - if let Some(s) = seen.get_mut(i) - && !*s - { - *s = true; - let score = self.prepared.get(i).map_or(0.0, |p| p.score(&text)); - if score >= CHOICE_FLOOR { - scored.push((i, score)); - } - } - } - scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); - match (scored.first(), scored.get(1)) { - (Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best), - _ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn candidates(list: &[(&str, &str)]) -> Vec { - list.iter().map(|(id, label)| Candidate { id: id.to_string(), labels: vec![label.to_string()] }).collect() - } - - fn proposed<'a>(name: &str, list: &'a [Candidate]) -> Option<&'a str> { - match propose(name, list) { - Proposal::Match(i, _) => list.get(i).map(|c| c.id.as_str()), - Proposal::Choice(_) => None, - } - } - - #[test] - fn extra_words_still_find_the_tool() { - let tools = candidates(&[ - ("tool:rectangle", "Rectangle Tool"), - ("tool:roundedRectangle", "Rounded Rectangle Tool"), - ("tool:rectangularGrid", "Rectangular Grid Tool"), - ("tool:ellipse", "Ellipse Tool"), - ]); - assert_eq!(proposed("Acme Rectangle Tool", &tools), Some("tool:rectangle")); - assert_eq!(proposed("Rounded Rectangle", &tools), Some("tool:roundedRectangle")); - assert_eq!(proposed("ellipse tool", &tools), Some("tool:ellipse"), "case aside, the label itself"); - } - - #[test] - fn letters_only_names_match_ids() { - let commands = candidates(&[("acme.copyAll", "Everything"), ("acme.copyAllOnPage", "Everything on This Page"), ("acme.clear", "Clear Out")]); - assert_eq!(proposed("acmecopyall", &commands), Some("acme.copyAll")); - assert_eq!(proposed("Acme Copy All On Page", &commands), Some("acme.copyAllOnPage")); - assert_eq!(proposed("clear out", &commands), Some("acme.clear")); - } - - #[test] - fn ties_need_a_choice() { - let tools = candidates(&[("tool:rectangle", "Rectangle Tool"), ("tool:ellipse", "Ellipse Tool"), ("tool:star", "Star Tool")]); - assert_eq!(propose("Tool", &tools), Proposal::Choice(vec![0, 1, 2])); - assert_eq!(propose("Nothing Alike", &tools), Proposal::Choice(vec![])); - assert_eq!(propose("", &tools), Proposal::Choice(vec![])); - } - - #[test] - fn names_without_spaces_match_by_character_pairs() { - let list = vec![ - Candidate { id: "tool:rectangle".into(), labels: vec!["Rectangle Tool".into(), "長方形ツール".into()] }, - Candidate { id: "tool:ellipse".into(), labels: vec!["Ellipse Tool".into(), "楕円形ツール".into()] }, - ]; - assert_eq!(proposed("長方形ツール", &list), Some("tool:rectangle")); - assert_eq!(proposed("楕円形 ツール", &list), Some("tool:ellipse"), "spaces and punctuation aside"); - assert_eq!(proposed("楕円形ツール2", &list), Some("tool:ellipse"), "most character pairs shared"); - } - - #[test] - fn the_index_finds_what_scoring_every_candidate_finds() { - let list = candidates(&[ - ("tool:rectangle", "Rectangle Tool"), - ("tool:roundedRectangle", "Rounded Rectangle Tool"), - ("tool:ellipse", "Ellipse Tool"), - ("select.all", "All"), - ("select.none", "Deselect"), - ("edit.undo", "Undo"), - ("foo.bar", "Zzz"), - ("panel:layers", "Layers"), - ]); - let matcher = Matcher::new(&list); - for name in ["Acme Rectangle Tool", "rounded", "acmecopyall", "foobar", "zz", "Lay", "Undo Redo", "ツール", "", "e"] { - let mut scored: Vec<(usize, f64)> = - list.iter().enumerate().map(|(i, c)| (i, score(name, c))).filter(|(_, s)| *s >= CHOICE_FLOOR).collect(); - scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); - let every = match (scored.first(), scored.get(1)) { - (Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best), - _ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()), - }; - assert_eq!(matcher.propose(name), every, "{name}"); - } - assert_eq!(proposed("foobar", &list), Some("foo.bar"), "the id alone, sharing no word or pair with the label"); - } - - #[test] - fn words_split_camel_case_and_separators() { - assert_eq!(words("selectAll_onArtboard 2x"), ["select", "all", "on", "artboard", "2x"]); - assert_eq!(words("Acme\u{a0}Lasso-Tool"), ["acme", "lasso", "tool"]); - assert_eq!(id_words("tool:roundedRectangle"), ["rounded", "rectangle"]); - } -} diff --git a/crates/engine/src/steps.rs b/crates/engine/src/steps.rs index f29cdacda..78c16cc9b 100644 --- a/crates/engine/src/steps.rs +++ b/crates/engine/src/steps.rs @@ -11,13 +11,20 @@ use serde_json::Value; /// Most nesting [`resolve`] walks into: deeper parameters are left as they are. const MAX_DEPTH: usize = 64; +/// The most the values references copy in may weigh together ([`weight`]: about their JSON size), +/// for one step's parameters. Each reference copies its value, so a short list of references to +/// one large result multiplied it. +const MAX_COPIED: usize = 256 << 20; + /// `params` with every step reference replaced from `results` (the results of the steps run so -/// far, in order); an error names a reference that resolves to nothing. +/// far, in order); an error names a reference that resolves to nothing, or the reference past +/// which the copied values weigh more than [`MAX_COPIED`]. pub fn resolve(params: &Value, results: &[Value]) -> Result { - resolve_at(params, results, 0) + let mut allowance = MAX_COPIED; + resolve_at(params, results, 0, &mut allowance) } -fn resolve_at(v: &Value, results: &[Value], depth: usize) -> Result { +fn resolve_at(v: &Value, results: &[Value], depth: usize, allowance: &mut usize) -> Result { if depth > MAX_DEPTH { return Ok(v.clone()); } @@ -25,16 +32,49 @@ fn resolve_at(v: &Value, results: &[Value], depth: usize) -> Result match s.strip_prefix("$$") { Some(rest) => Value::String(format!("${rest}")), None => match parse(s) { - Some((step, path)) => lookup(s, step, &path, results)?, + Some((step, path)) => { + let found = lookup(s, step, &path, results)?; + *allowance = weight(found, *allowance) + .and_then(|w| allowance.checked_sub(w)) + .ok_or_else(|| format!("`{s}`: the step's references copy in more than {} MB of values", MAX_COPIED >> 20))?; + found.clone() + } None => v.clone(), }, }, - Value::Array(a) => Value::Array(a.iter().map(|x| resolve_at(x, results, depth + 1)).collect::>()?), - Value::Object(o) => Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), resolve_at(x, results, depth + 1)?))).collect::>()?), + Value::Array(a) => Value::Array(a.iter().map(|x| resolve_at(x, results, depth + 1, allowance)).collect::>()?), + Value::Object(o) => { + Value::Object(o.iter().map(|(k, x)| Ok((k.clone(), resolve_at(x, results, depth + 1, allowance)?))).collect::>()?) + } _ => v.clone(), }) } +/// About the bytes `v` takes as JSON (8 per value, plus its strings and keys); `None` once that +/// passes `limit`, without looking further. +fn weight(v: &Value, limit: usize) -> Option { + let mut total = 0usize; + let mut todo = vec![v]; + while let Some(v) = todo.pop() { + total = total.checked_add(8)?; + match v { + Value::String(s) => total = total.checked_add(s.len())?, + Value::Array(a) => todo.extend(a), + Value::Object(o) => { + for (k, x) in o { + total = total.checked_add(k.len())?; + todo.push(x); + } + } + _ => {} + } + if total > limit { + return None; + } + } + Some(total) +} + /// Which step a reference names. #[derive(Debug, PartialEq)] enum Step { @@ -71,6 +111,9 @@ fn parse(s: &str) -> Option<(Step, Vec)> { rest = r; } else { let (n, r) = rest.strip_prefix('[')?.split_once(']')?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } path.push(Seg::Index(n.parse().ok()?)); rest = r; } @@ -78,7 +121,7 @@ fn parse(s: &str) -> Option<(Step, Vec)> { Some((step, path)) } -fn lookup(text: &str, step: Step, path: &[Seg], results: &[Value]) -> Result { +fn lookup<'a>(text: &str, step: Step, path: &[Seg], results: &'a [Value]) -> Result<&'a Value, String> { let (i, found) = match step { Step::Index(i) => (i, results.get(i)), Step::Last => (results.len().saturating_sub(1), results.last()), @@ -93,7 +136,7 @@ fn lookup(text: &str, step: Step, path: &[Seg], results: &[Value]) -> Result