diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..b83d5a8f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + timeout-minutes: 90 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6ee868152..0f91ed6d0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,25 +1,19 @@ # FreeBSD build and package check in a QEMU VM. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: - branches: [main] - paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "packaging/env.sh" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "packaging/env.sh" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh + schedule: + - cron: "17 4 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54a8e53fb..5fd996c43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,14 +13,56 @@ on: default: "" permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources + permissions: + contents: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d..e78fe1ea3 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: 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/ASSETS.md b/ASSETS.md index f8121c35b..9f254a76d 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -169,6 +169,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | | | `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-para-bullets.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three dots, each before a line | MIT OR Apache-2.0 | | | `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-justify-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | @@ -348,20 +349,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 193dc0946..3fe9b387c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -876,7 +876,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[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", @@ -1844,8 +1844,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,7 +1853,7 @@ 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", @@ -1872,7 +1871,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 +1889,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", @@ -2296,7 +2294,7 @@ dependencies = [ [[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", @@ -4767,6 +4765,7 @@ dependencies = [ "hayro-ccitt", "image", "kurbo 0.13.1", + "memchr", "ruzstd", "serde_json", "vectorcraft-brush", diff --git a/Cargo.toml b/Cargo.toml index e6efb9342..f5d45788b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ 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-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } @@ -91,6 +91,8 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } # CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through # `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version. hayro-ccitt = "0.3" +# Substring search for the EPS reader's PostScript `search`. +memchr = "2" jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG @@ -122,18 +124,24 @@ 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" } diff --git a/NOTICE b/NOTICE index 5cf3c8952..0d6f9dd09 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. diff --git a/ROADMAP.md b/ROADMAP.md index 54481f2d3..28c15f326 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -75,7 +75,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so | Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 | | Strokes, brushes, width profiles | 88% | 8–15 | | Appearance, transparency, graphic styles, masks | 97% | 1–2 | -| Live vector effects | 85% | 6–10 | +| Live vector effects | 85% | 4–8 | | Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 | | 3D and Materials | ~25% | 30–55 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | @@ -128,6 +128,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. - 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph. - 2026-10-22: The Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step. Filter thumbnails and a preview area inside the dialog remain ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Raster effects 82% → 85% (estimated); headline numbers unchanged. @@ -197,6 +198,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. | | 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index ef6f299f3..d760cfefe 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -285,7 +285,7 @@ fn deep(n: usize) -> Result<(), String> { let key = format!("photo {i}"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo)); let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0); - let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() }; + let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default(), pixels: None }; let id = d.alloc_id(); d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?; } 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/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 165c0a81d..957d302d5 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -143,6 +143,13 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { Some(p) if p.ends_with('\r') => PathBuf::from(p.trim_end_matches('\r')), _ => path, }; + // A paste sent by automation reads only what its roots allow. + match path.to_str() { + Some(p) => vectorcraft_engine::file_access::check_read(p).ok()?, + // A lossy name would check another file than the one opened. + None if vectorcraft_engine::file_access::current().is_none() => {} + None => return None, + } let mut head = Vec::with_capacity(FILE_HEAD); std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?; let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?; diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs index 845d44d82..fe2097157 100644 --- a/apps/vectorcraft/src/mac_save_format.rs +++ b/apps/vectorcraft/src/mac_save_format.rs @@ -243,12 +243,4 @@ mod tests { assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}"); } } - - /// The menu's label is translated in every language with complete menus. - #[test] - fn the_label_is_translated() { - for lang in i18n::Lang::all().filter(|l| l.complete_menus()) { - assert!(i18n::has(lang, "Format:"), "{}", lang.code()); - } - } } 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 b819ff185..79e4c1929 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")] @@ -157,7 +158,11 @@ impl App { // again on its document when it answers. #[cfg(target_os = "macos")] if !self.app.file_dialog_open() { - open_files(&mut self.app, open_documents::take()); + let files = open_documents::take(); + if !files.is_empty() { + bring_window_forward(ctx); + } + open_files(&mut self.app, files); } self.app.logic(ctx); if self.pending_maximize { @@ -191,6 +196,14 @@ fn end_before_teardown(frames: u64) { } } +/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back +/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn bring_window_forward(ctx: &egui::Context) { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); +} + /// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. fn open_files(app: &mut VectorcraftApp, files: Vec) { @@ -568,6 +581,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())), @@ -1012,11 +1028,13 @@ fn main() -> std::process::ExitCode { } // Linux may not map the window before the first frames, so a saved maximize is retried then (#3635). let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized); - // User Defined swatch and graphic style libraries live next to the preferences. + // User Defined swatch, graphic style and symbol libraries live next to the preferences. let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string())); app.session.style_libraries.set_user_dir(styles); + let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string())); + app.session.symbol_libraries.set_user_dir(symbols); // With the User Defined libraries listed, the persistent library panels reopen. vectorcraft_ui_egui::panels::library_panel::restore(&mut app); // So do the Libraries panel's libraries; runs without preferences (agents' test @@ -1126,6 +1144,17 @@ mod tests { vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } } + #[test] + fn opening_files_unminimizes_and_focuses_the_window() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx())); + // Nothing paints in the test: drop the font atlas upload egui expects to be applied. + out.textures_delta.clear(); + let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands; + assert!(cmds.contains(&egui::ViewportCommand::Minimized(false))); + assert!(cmds.contains(&egui::ViewportCommand::Focus)); + } + #[test] fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { use serde_json::json; diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs index 58b1afeeb..f2c1fd725 100644 --- a/apps/vectorcraft/src/tablet.rs +++ b/apps/vectorcraft/src/tablet.rs @@ -13,6 +13,12 @@ //! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so //! the last pressure of each pen is kept. //! +//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains +//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's +//! "… Eraser (0x…)" of type `ERASER`, XWayland's "xwayland-tablet eraser:N"). Its samples go into +//! the feed marked as the eraser end, and the canvas switches to the Eraser tool +//! ([`vectorcraft_ui_egui::stylus`]). +//! //! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland //! that receives none of the window's input. //! @@ -22,7 +28,7 @@ use std::collections::HashMap; -use vectorcraft_ui_egui::stylus::StylusFeed; +use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; /// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer /// than 16). @@ -62,12 +68,14 @@ struct Device { id: u16, /// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none. pressure: Option, + /// The eraser end of a pen: its name or its XInput type contains "eraser", in any case. + eraser: bool, } impl Device { - /// From what the X server lists for the device: its XInput type (`ListInputDevices`), and each - /// valuator's number, label, minimum and maximum (`XIQueryDevice`). - fn new(id: u16, kind: Option<&str>, valuators: impl IntoIterator) -> Self { + /// From what the X server lists for the device: its name and each valuator's number, label, + /// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`). + fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator) -> Self { let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k)); let pressure = valuators .into_iter() @@ -75,7 +83,8 @@ impl Device { .find(|(_, label, ..)| label == "Abs Pressure") .map(|(number, _, min, max)| Axis { number, min, max }) .filter(|_| !finger); - Self { id, pressure } + let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser")); + Self { id, pressure, eraser } } } @@ -84,8 +93,8 @@ impl Device { enum Update { /// Nothing: a device the last listing didn't have (a master, or one added since). Keep, - /// The pressure of a pen, or `None` for another pointer. - Set(Option), + /// The pressure and end of a pen, or `None` for another pointer. + Set(Option), } /// The pointer devices and the last pressure of each pen. @@ -106,20 +115,21 @@ impl Pens { fn handle(&mut self, id: u16, valuators: impl IntoIterator) -> Update { let Some(device) = self.devices.get(&id) else { return Update::Keep }; let Some(axis) = device.pressure else { return Update::Set(None) }; + let eraser = device.eraser; let last = self.last.entry(id).or_insert(0.0); for (number, v) in valuators { if number == axis.number { *last = axis.pressure(v); } } - Update::Set(Some(*last)) + Update::Set(Some(PenSample { pressure: *last, eraser })) } /// A raw event as the X server sends it: its valuator mask and the values of the valuators - /// set in it. A pen's pressure or another pointer's `None` goes into `feed`. + /// set in it. A pen's sample or another pointer's `None` goes into `feed`. fn raw_event(&mut self, feed: &StylusFeed, id: u16, mask: &[u32], values: &[f64]) { - if let Update::Set(pressure) = self.handle(id, valuators(mask, values)) { - feed.set(pressure); + if let Update::Set(sample) = self.handle(id, valuators(mask, values)) { + feed.set_sample(sample); } } } @@ -214,7 +224,7 @@ mod x11 { } } - /// The slave pointer devices with their pressure valuators (none on any error). + /// The slave pointer devices with their names and pressure valuators (none on any error). fn query_devices(conn: &RustConnection) -> Vec { let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() }; let kinds = device_kinds(conn); @@ -230,7 +240,8 @@ mod x11 { } _ => None, }); - out.push(Device::new(info.deviceid, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::>())); + let name = String::from_utf8_lossy(&info.name); + out.push(Device::new(info.deviceid, &name, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::>())); } out } @@ -266,11 +277,11 @@ mod tests { } fn pen(id: u16) -> Device { - Device::new(id, Some("STYLUS"), pen_valuators()) + Device::new(id, "Wacom Intuos Pro M Pen stylus", Some("STYLUS"), pen_valuators()) } fn mouse(id: u16) -> Device { - Device::new(id, Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)]) + Device::new(id, "Logitech USB Optical Mouse", Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)]) } #[test] @@ -286,20 +297,70 @@ mod tests { fn a_device_with_an_abs_pressure_valuator_is_a_pen() { assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 })); assert_eq!(mouse(12).pressure, None); - let pad = Device::new(13, Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]); + let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]); assert_eq!(pad.pressure, None, "a tablet's pad is no pen"); - let far = Device::new(14, Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]); + let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]); assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored"); // The types libinput and XWayland give pens, and a device whose type wasn't listed. for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] { - assert!(Device::new(15, kind, pen_valuators()).pressure.is_some(), "{kind:?}"); + assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}"); } // xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure". for kind in ["TOUCHPAD", "TOUCHSCREEN"] { - assert_eq!(Device::new(16, Some(kind), pen_valuators()).pressure, None, "{kind}"); + assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}"); } } + /// The eraser end of a pen is a device of its own, whose name or XInput type contains + /// "eraser", as xf86-input-wacom, xf86-input-libinput and XWayland list it. + #[test] + fn an_eraser_device_is_named_so() { + let erasers = [ + ("Wacom Intuos Pro M Pen eraser", Some("ERASER")), + ("HUION Kamvas Pen Eraser (0x1)", Some("ERASER")), + ("xwayland-tablet eraser:16", Some("xwayland-tablet eraser")), + ("Wacom Intuos Pro M Pen eraser", None), + ("Tablet Pen (0x2)", Some("ERASER")), + ]; + for (name, kind) in erasers { + let d = Device::new(10, name, kind, pen_valuators()); + assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})"); + } + let tips = [ + ("Wacom Intuos Pro M Pen stylus", Some("STYLUS")), + ("HUION Kamvas Pen Pen (0x1)", Some("STYLUS")), + ("xwayland-tablet stylus:16", Some("xwayland-tablet stylus")), + ("Logitech USB Optical Mouse", Some("MOUSE")), + ("", None), + ]; + for (name, kind) in tips { + assert!(!Device::new(9, name, kind, pen_valuators()).eraser, "{name} ({kind:?})"); + } + } + + #[test] + fn the_eraser_device_sets_the_eraser_end() { + let mut pens = Pens::default(); + pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]); + let feed = StylusFeed::default(); + pens.raw_event(&feed, 9, &[0b100], &[32767.5]); + assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false }))); + assert_eq!(feed.eraser(), Some(false)); + // Turned over, the pen hovers with its eraser end: the eraser device's first event, with + // no pressure yet. + pens.raw_event(&feed, 10, &[0b1], &[100.0]); + assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true }))); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0))); + pens.raw_event(&feed, 10, &[0b100], &[65535.0]); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0))); + // Back to the tip, with the tip's own last pressure. + pens.raw_event(&feed, 9, &[0b1], &[200.0]); + assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.5))); + // A mouse: no pen end. + pens.raw_event(&feed, 12, &[0b1], &[3.0]); + assert_eq!(feed.eraser(), None); + } + #[test] fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() { let mut pens = Pens::default(); @@ -331,18 +392,18 @@ mod tests { let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)]; for (min, max) in ranges { let mut pens = Pens::default(); - pens.set_devices([Device::new(1, None, [(2, "Abs Pressure".to_string(), min, max)])]); + pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]); for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] { - let Update::Set(Some(p)) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") }; + let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") }; assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}"); } } let mut pens = Pens::default(); - pens.set_devices((0..2000u16).map(|i| Device::new(i, None, []))); + pens.set_devices((0..2000u16).map(|i| Device::new(i, "", None, []))); assert!(pens.devices.len() <= MAX_DEVICES, "the device list is bounded"); // Values for valuators the pen doesn't have are ignored. pens.set_devices([pen(3)]); - let Update::Set(Some(p)) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::>()) else { + let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::>()) else { panic!("the pen gave no pressure") }; assert!(p < 0.001, "{p}"); 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-bullets.svg b/assets/icons/dc-para-bullets.svg new file mode 100644 index 000000000..b87e67860 --- /dev/null +++ b/assets/icons/dc-para-bullets.svg @@ -0,0 +1 @@ + diff --git a/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 ff2e84bb8..c65862e67 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -23,12 +23,17 @@ const PT_PER_MM: f64 = 72.0 / 25.4; const THINNEST: f64 = 0.25; /// The most objects an import makes. const MAX_NODES: usize = 2_000_000; +/// The most block visits (one per insert copy) an import makes, whatever they produce. +const MAX_VISITS: usize = 10_000_000; +/// The most path elements an import makes (blocks drawn as art are copied for every placement). +const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 }; /// How far from the origin art may lie (points): the canvas. pub(crate) const MAX_EXTENT: f64 = 4.0e6; const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out"; const XREFS: &str = "blocks referencing other drawings (external references) are left out"; const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out"; +const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out"; /// What a block's art takes from the insert that places it: its colour, lineweight, linetype /// and opacity where the art says "by block", and its layer for art on layer 0. @@ -63,6 +68,9 @@ pub(crate) struct Build<'d, 'a> { /// Lineweights are multiplied by this. lw_scale: f64, nodes: usize, + visits: usize, + /// Path elements made so far. + elements: usize, warnings: Vec, } @@ -118,6 +126,8 @@ impl<'d, 'a> Build<'d, 'a> { symbol_map: Affine::IDENTITY, lw_scale: 1.0, nodes: 0, + visits: 0, + elements: 0, warnings: vec![], } } @@ -367,7 +377,8 @@ impl<'d, 'a> Build<'d, 'a> { let k = self.d.header.ltscale * p.ltscale * scale; // Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap… let mut out: Vec = vec![]; - for v in pattern { + // (A linetype has a few elements; every path keeps its own copy of the dashes.) + for v in pattern.iter().take(64) { let dash = *v >= 0.0; let len = v.abs() * k; match out.len().is_multiple_of(2) { @@ -447,6 +458,12 @@ impl<'d, 'a> Build<'d, 'a> { } fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option { + let n = bp.elements().len(); + if self.elements.saturating_add(n) > MAX_ELEMENTS { + self.warn(TOO_MANY_POINTS); + return None; + } + self.elements += n; let mut bp = bp.clone(); bp.apply_affine(map); super::curve::bounds(&bp)?; @@ -557,6 +574,13 @@ impl<'d, 'a> Build<'d, 'a> { /// The art of block `name` drawn by `map`, as one object. fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option { + self.visits += 1; + if self.visits > MAX_VISITS { + if self.visits == MAX_VISITS + 1 { + self.warn(TOO_MANY); + } + return None; + } let items = self.block(name)?; if !self.enter(name, depth) { return None; diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index f24f05596..59e1283cc 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -17,6 +17,8 @@ const DESCENT: f64 = 0.3; const LINE_SPACING: f64 = 5.0 / 3.0; /// The most copies one array insert (MINSERT) makes. const MAX_CELLS: i64 = 10_000; +/// The most copies all the array inserts of a file make together. +const MAX_TOTAL_CELLS: i64 = 2_000_000; /// Art farther than this from the origin (drawing units) is damaged data, left out. const MAX_COORD: f64 = 1e12; /// How far fit and aligned text may be squeezed or stretched to span their two points: at width @@ -184,12 +186,15 @@ pub(crate) struct Converter { /// Entity types left out, with how many. pub skipped: BTreeMap, pub warnings: Vec, + /// Array insert copies made so far, against [`MAX_TOTAL_CELLS`]. + cells: i64, } const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries"; const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour"; const MESHES: &str = "3D polygon meshes and polyface meshes are left out"; const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each"; +const MANY_ARRAYS: &str = "array inserts are limited to 2,000,000 copies in all"; const FAR_AWAY: &str = "entities with coordinates out of range are left out"; impl Converter { @@ -294,6 +299,15 @@ impl Converter { } else { (cols, rows) }; + // Past the file-wide budget, each insert keeps only its first copy. + let left = (MAX_TOTAL_CELLS - self.cells).max(1); + let (cols, rows) = if cols * rows > left { + self.warn(MANY_ARRAYS); + (cols.min(left), (left / cols.min(left)).max(1).min(rows)) + } else { + (cols, rows) + }; + self.cells = self.cells.saturating_add(cols * rows); let cells = (0..rows) .flat_map(|r| (0..cols).map(move |c| (r, c))) .map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy)) diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs index 1d83475c3..a2d1dc11d 100644 --- a/crates/cad/src/import/reader.rs +++ b/crates/cad/src/import/reader.rs @@ -423,6 +423,7 @@ fn font_family(g: &[Pair<'_>]) -> String { /// objects but with paper-space art has one paper layout. fn layouts(objects: &[Record<'_>], block_records: &HashMap, entities: &[Record<'_>]) -> Vec { let mut paper: Vec<(i64, LayoutDef)> = vec![]; + let mut seen = std::collections::HashSet::new(); 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(&[]); @@ -432,7 +433,7 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap, enti continue; } let Some(block) = block else { continue }; - if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) { + if seen.insert(name.to_ascii_lowercase()) { paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) })); } } diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 4845e8d2a..d0dde65c9 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -799,6 +799,48 @@ fn array_inserts_attributes_and_dimensions() { assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0); } +#[test] +fn nested_array_inserts_of_nothing_stop_at_the_work_budget() { + let block = |name: &'static str, inner: Option<&'static str>| { + let mut b = vec![(0, "BLOCK"), (2, name), (70, "1"), (10, "0"), (20, "0")]; + if let Some(i) = inner { + b.extend([(0, "INSERT"), (2, i), (10, "0"), (20, "0"), (70, "300"), (44, "1")]); + } + b.push((0, "ENDBLK")); + b + }; + let mut blocks = block("*A", Some("*B")); + blocks.extend(block("*B", Some("*C"))); + blocks.extend(block("*C", None)); + let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")]; + let r = open(&drawing(&[], &[], &blocks, &top), &points()); + assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings); +} + +#[test] +fn an_array_of_a_large_anonymous_block_stops_at_the_path_element_budget() { + // A 2,000-vertex polyline in *U1, placed in a 100 × 10 array: 2,000,000 path elements when + // every cell is copied, twice the budget tests import with. + let mut owned: Vec<(i32, String)> = vec![(0, "BLOCK".into()), (2, "*U1".into()), (70, "1".into()), (10, "0".into()), (20, "0".into())]; + owned.extend([(0, "LWPOLYLINE".into()), (90, "2000".into()), (70, "0".into())]); + for i in 0..2000 { + owned.extend([(10, i.to_string()), (20, (i % 2).to_string())]); + } + owned.push((0, "ENDBLK".into())); + let blocks: Vec<(i32, &str)> = owned.iter().map(|(c, v)| (*c, v.as_str())).collect(); + let inserts = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "100"), (71, "10"), (44, "2000"), (45, "2")]; + let r = open(&drawing(&[], &[], &blocks, &inserts), &points()); + let mut elements = 0; + r.document.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + elements += path.to_bezpath().elements().len(); + } + }); + assert!(elements > 0); + assert!(elements <= 1_000_000, "{elements} path elements"); + assert!(r.warnings.iter().any(|w| w.contains("path points")), "{:?}", r.warnings); +} + #[test] fn blocks_inside_themselves_are_left_out_without_hanging() { let blocks = [ @@ -1061,3 +1103,49 @@ fn entities_extruded_along_minus_z_are_mirrored() { let b = bounds(&leaves(&doc)[0]); assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}"); } + +/// A drawing with one paper block record (handle `A`) and a LAYOUT object per name. +fn with_layouts(names: &[String]) -> Vec { + let mut s = String::from( + " 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nBLOCK_RECORD\n 0\nBLOCK_RECORD\n 5\nA\n 2\n*Paper_Space\n 0\nENDTAB\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n", + ); + for n in names { + s.push_str(&format!(" 0\nLAYOUT\n100\nAcDbLayout\n 1\n{n}\n330\nA\n")); + } + s.push_str(" 0\nENDSEC\n 0\nEOF\n"); + s.into_bytes() +} + +#[test] +fn many_paper_layouts_are_read_in_linear_time() { + let names: Vec = (0..100_000).map(|i| format!("L{i}")).collect(); + let bytes = with_layouts(&names); + let t = std::time::Instant::now(); + let info = info(&bytes).unwrap(); + assert_eq!(info.layouts.len(), names.len() + 1); + assert!(t.elapsed() < std::time::Duration::from_secs(5), "layout de-duplication is quadratic: {:?}", t.elapsed()); +} + +#[test] +fn paper_layout_names_are_unique_ignoring_case() { + let names = ["Layout1", "LAYOUT1", "Other"].map(String::from); + let info = info(&with_layouts(&names)).unwrap(); + assert_eq!(info.layouts, ["Model", "Layout1", "Other"]); +} + +#[test] +fn many_array_inserts_of_a_missing_block_stay_bounded() { + let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")]; + let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect(); + let bytes = entities(&e); + let text = reader::decode(&bytes); + let (pairs, _) = reader::pairs(&text).unwrap(); + let d = reader::Drawing::read(&pairs); + let model = d.layout("Model").or(d.layouts.first()).unwrap(); + let mut conv = entity::Converter::default(); + let items = conv.items(&d.layout_records(model)); + assert_eq!(items.len(), 3000); + let total: usize = items.iter().map(|i| if let entity::Geom::Insert(ins) = &i.geom { ins.cells.len() } else { 0 }).sum(); + assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}"); + assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings); +} diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 297f9f950..ab9137050 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -168,12 +168,32 @@ fn subpaths(bp: &BezPath) -> Vec { out } +/// The most pixels of an image written (it is held decoded, as RGBA and encoded at once). +const MAX_IMAGE_PIXELS: u64 = 64 << 20; + +/// The most entities (handles) a drawing is written with. +const MAX_ENTITIES: u32 = 10_000_000; + +/// The largest x or y a path may have to be flattened, and the most points its polygons keep. +const MAX_COORD: f64 = 1e9; +const MAX_POINTS: usize = 4_000_000; + /// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped. fn polygons(bp: &BezPath) -> Vec> { + // Far outside any drawing, a curve flattens to millions of points at this tolerance. + let b = bp.control_box(); + if !matches!( + b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()).partial_cmp(&MAX_COORD), + Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal) + ) { + return vec![]; + } let mut out: Vec> = vec![]; let mut cur: Vec = vec![]; - let push = |p: Point, cur: &mut Vec| { - if cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + let mut points = 0usize; + let mut push = |p: Point, cur: &mut Vec| { + if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + points += 1; cur.push(p); } }; @@ -208,21 +228,37 @@ fn symbol_name(name: &str, long: bool) -> String { s.trim().to_string() } -/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters. -fn unique(base: &str, taken: &[&str], max: usize) -> String { - let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() }; - let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); - if free(&base) { - return base; +/// The names a DXF table holds (compared in any case), and the next suffix to try for each base, +/// so identical names don't each search the suffixes before. +#[derive(Default)] +struct Names { + taken: std::collections::HashSet, + next: HashMap, +} + +impl Names { + /// Names that are there already. + fn with(names: &[&str]) -> Self { + Self { taken: names.iter().map(|n| n.to_lowercase()).collect(), next: HashMap::new() } } - (2..) - .map(|i| { - let suffix = format!("_{i}"); + + /// `base` made unique by `_2`, `_3`…, within `max` characters; the name is taken from now on. + fn unique(&mut self, base: &str, max: usize) -> String { + let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() }; + if self.taken.insert(base.to_lowercase()) { + return base; + } + let next = self.next.entry(base.to_lowercase()).or_insert(2); + loop { + let suffix = format!("_{next}"); + *next += 1; let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect(); - format!("{keep}{suffix}") - }) - .find(|n| free(n)) - .unwrap_or(base) + let name = format!("{keep}{suffix}"); + if self.taken.insert(name.to_lowercase()) { + return name; + } + } + } } fn overlaps(a: Rect, b: Rect) -> bool { @@ -251,12 +287,14 @@ pub(crate) struct Scene<'a> { e: Codes, next: u32, layers: Vec, + layer_names: Names, /// The DXF layer art goes on, and its flags. layer: String, flags: LayerFlags, linetypes: Vec, /// (style name, font family). styles: Vec<(String, String)>, + style_names: Names, images: Vec, files: Vec, /// Image keys → index into `images` (`None`: left out). @@ -283,10 +321,12 @@ impl<'a> Scene<'a> { e: Codes::new(o.version), next: fixed::FIRST_FREE, layers: vec![], + layer_names: Names::with(&["0", "Defpoints"]), layer: "0".into(), flags: LayerFlags { off: false, locked: false, plot: true }, linetypes: vec![], styles: vec![], + style_names: Names::with(&[crate::writer::STANDARD_STYLE]), images: vec![], files: vec![], by_key: HashMap::new(), @@ -350,6 +390,12 @@ impl<'a> Scene<'a> { if let NodeKind::Layer { template: true, .. } = n.kind { return; } + // Symbols that hold symbols are written in full for each instance: depth alone doesn't + // bound how many entities that is. + if self.next.saturating_sub(fixed::FIRST_FREE) > MAX_ENTITIES { + self.warn("the drawing is too large for DXF: what follows 10 million entities was left out"); + return; + } // Hidden layers are written, switched off. let layer = n.is_layer(); if !force && !n.visible && !layer { @@ -452,9 +498,7 @@ impl<'a> Scene<'a> { fn add_layer(&mut self, n: &Node) -> String { let long = self.o.version.r2000(); let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long); - let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect(); - taken.extend(["0", "Defpoints"]); - let name = unique(&base, &taken, if long { 255 } else { 31 }); + let name = self.layer_names.unique(&base, if long { 255 } else { 31 }); let aci = match n.kind { NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256), _ => 7, @@ -855,11 +899,18 @@ impl<'a> Scene<'a> { let angle = b.atan2(a).to_degrees(); for line in &layout.lines { let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue }; - for run in glyphs.chunk_by(|x, y| x.run == y.run) { - let Some(first) = run.first() else { continue }; + // A list marker is a TEXT entity of its own, holding the marker's characters where its + // characters' glyphs are drawn (not its tab's). + let marked: Vec<(usize, &vectorcraft_text::PositionedGlyph, Option<&vectorcraft_text::ListMarker>)> = + glyphs.iter().enumerate().map(|(k, g)| (line.glyph_start + k, g, layout.marker_of(line.glyph_start + k))).collect(); + for chunk in marked.chunk_by(|x, y| x.1.run == y.1.run && x.2.is_some() == y.2.is_some()) { + let run: Vec<&vectorcraft_text::PositionedGlyph> = + chunk.iter().filter(|(k, _, m)| m.is_none_or(|m| m.tab != *k)).map(|(_, g, _)| *g).collect(); + 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 Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue }; + 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 }; 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 }; @@ -897,9 +948,7 @@ impl<'a> Scene<'a> { return name.clone(); } let long = self.o.version.r2000(); - let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect(); - taken.push(crate::writer::STANDARD_STYLE); - let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 }); + let name = self.style_names.unique(&symbol_name(family, long), if long { 255 } else { 31 }); self.styles.push((name.clone(), family.to_string())); name } @@ -910,7 +959,7 @@ impl<'a> Scene<'a> { self.warn(NO_IMAGES); return; } - let Some(i) = self.image_def(&im.key) else { return }; + let Some(i) = self.image_def(im) else { return }; let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return }; let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1))); let map = self.to_dxf * im.xf; @@ -952,12 +1001,13 @@ impl<'a> Scene<'a> { } } - /// The image file for image `key`, encoded once in the chosen format (`None`: left out). - fn image_def(&mut self, key: &str) -> Option { + /// The image file for image `im`, encoded once in the chosen format (`None`: left out). + fn image_def(&mut self, im: &ImageObject) -> Option { + let key = im.key.as_str(); if let Some(i) = self.by_key.get(key) { return *i; } - let made = self.encode_image(key); + let made = self.encode_image(im); let i = match made { Ok((file, size)) => { let handle = self.handle(); @@ -974,9 +1024,18 @@ impl<'a> Scene<'a> { i } - /// Image `key` as a file in the chosen format, and its size in pixels. - fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> { - let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?; + /// Image `im` as a file in the chosen format, and its size in pixels. + fn encode_image(&self, im: &ImageObject) -> Result<(DxfImage, (u32, u32)), String> { + let blob = self.doc.image_blob(im).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?; + // The pixels it converts to (RGBA, 4 bytes each) are read from its header before decoding. + let (w, h) = image::ImageReader::new(Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .ok_or_else(|| UNDECODED.to_string())?; + if u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS { + return Err("an image is too large for DXF and was left out".to_string()); + } let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?; let size = (img.width(), img.height()); let mut bytes = vec![]; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 95be8540e..92f701d47 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -456,6 +456,42 @@ fn type_is_text_or_outlines() { } } +/// A list paragraph's bullet is a TEXT entity of its own where it is drawn, and the paragraph's +/// text starts after it, at the left indent. +#[test] +fn a_bullet_is_text_of_its_own_before_its_paragraph() { + let mut d = text_doc("Item", 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(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + } + let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(kinds(&out), ["TEXT", "TEXT"]); + assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("\u{2022}"), Some("Item"))); + assert_eq!((all(&e[0], 10)[0], all(&e[1], 10)[0]), (10.0, 28.0), "the bullet at the anchor, the text 18 pt on"); + // In a right-to-left paragraph the bullet is drawn after the text, at the right, and its tab + // lies between the two. + let mut d = text_doc("שלום", 20.0); + let id = d.layers[0].children().unwrap()[0].id; + let mut bullet_x = f64::NAN; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let m = &lay.markers[0]; + let bullet = m.glyphs.clone().find(|&i| i != m.tab).unwrap(); + assert!(lay.glyphs[m.tab].origin.x < lay.glyphs[bullet].origin.x, "the tab before the bullet"); + bullet_x = 10.0 + lay.glyphs[bullet].origin.x; + } + let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(kinds(&out), ["TEXT", "TEXT"]); + assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("שלום"), Some("\u{2022}"))); + 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]); +} + /// 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() { @@ -500,6 +536,7 @@ fn images_link_png_or_jpeg_files() { xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), link: None, placement: Default::default(), + pixels: None, }; vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))] }); @@ -533,7 +570,15 @@ fn images_without_pixels_are_left_out_with_one_note() { ["raster-1", "raster-2"] .into_iter() .map(|key| { - let im = ImageObject { key: key.into(), width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { + key: key.into(), + width: 4, + height: 2, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) }) .collect() diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 9af14253d..5de064d6b 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -111,6 +111,16 @@ impl Color { pub fn out_of_gamut(&self) -> bool { cms::active().out_of_gamut(self) } + /// The colour exactly, as a key: its model and its components' bits (colours that only look + /// alike, as a grey and the same RGB grey, differ). + pub fn key(&self) -> [u32; 5] { + match *self { + Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits(), 0], + Color::Cmyk { c, m, y, k } => [1, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()], + Color::Gray { k } => [2, k.to_bits(), 0, 0, 0], + Color::Lab { l, a, b } => [3, l.to_bits(), a.to_bits(), b.to_bits(), 0], + } + } pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] { let [r, g, b] = self.to_rgb(); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index 065e06eed..80f740321 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -181,7 +181,9 @@ fn css_comment(s: &str) -> String { fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name)); - let mut taken: Vec = vec![]; + let mut taken: HashSet = HashSet::new(); + // The next number to try for each base, so repeated names don't search the ones used before. + let mut next: HashMap = HashMap::new(); let mut props = |out: &mut String, list: &[Swatch]| { for w in list.iter().filter(&keep) { let value = match &w.paint { @@ -198,8 +200,18 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { }; // Names differing only in case or punctuation map to one property: number the others. let base = css_property(&w.name); - let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base); - taken.push(name.clone()); + let name = if taken.insert(base.clone()) { + base + } else { + let n = next.entry(base.clone()).or_insert(1); + loop { + *n += 1; + let name = format!("{base}-{n}"); + if taken.insert(name.clone()) { + break name; + } + } + }; let note = if w.spot { " /* spot */" } else { "" }; out.push_str(&format!(" {name}: {value};{note}\n")); } 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/hit.rs b/crates/doc/src/hit.rs index 18deed56b..6f142a584 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -547,7 +547,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } - NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)), + NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)), NodeKind::Mesh(m) => { let bp = m.outline().to_bezpath(); if stroke_contains(&bp, 0.0, opt.tol, p) { @@ -659,7 +659,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r) .is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))), NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)), - NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)), + NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)), // A box with no width or no height (an instance of a symbol that draws a straight // line) is touched where the marquee crosses it. _ => n.geometric_bounds_with(s).is_some_and(|b| { @@ -1173,7 +1173,7 @@ mod tests { d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); // 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path. let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0); - let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default() }; + let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); let path_only = HitOptions { path_only: true, ..Default::default() }; let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9f954603c..4e42295f1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -456,6 +456,18 @@ pub struct Artboard { /// be set. #[serde(default)] pub show_cross_hairs: bool, + /// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and + /// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written + /// only when on, so a document that never shows them saves as before. + #[serde(default, skip_serializing_if = "skip::is_default")] + pub show_video_safe_areas: bool, + /// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its + /// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many + /// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't + /// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is + /// brought into it as it opens. + #[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")] + pub video_ruler_pixel_aspect_ratio: f64, /// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the /// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on; /// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave @@ -464,6 +476,42 @@ pub struct Artboard { pub color: Option, } +/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times +/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and +/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare. +pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive = 0.1..=10.0; + +/// How far in from each edge of an artboard the video safe areas lie, as a share of its width +/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the +/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television +/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5). +pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05]; + +fn square_pixels() -> f64 { + 1.0 +} + +fn is_square_pixels(v: &f64) -> bool { + *v == 1.0 +} + +/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that +/// isn't a number (NaN, infinite) reads as square pixels. +fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = f64::deserialize(d)?; + let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end()); + Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 }) +} + +impl Artboard { + /// The action-safe and the title-safe area of the artboard, in that order, each inset from its + /// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas). + pub fn video_safe_areas(&self) -> [Rect; 2] { + let r = self.rect; + VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height())) + } +} + /// A document's locked artboards ([`Document::locked_artboards`]). pub struct LockedArtboards<'a> { rects: Vec, @@ -1131,6 +1179,8 @@ impl Document { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }], layers: vec![], @@ -1899,10 +1949,14 @@ impl Document { pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { self.patterns.iter_mut().find(|p| p.name == name) } - /// Deep-clone `node` with fresh ids for it and all descendants. + /// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken + /// in ([`Self::adopt_pixels`]). pub fn reid(&mut self, node: &Node) -> Node { let mut n = node.clone(); n.id = self.alloc_id(); + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } // A copied annotation must never bind its source ids to unrelated art in a different // document. Keep the measured geometry; new measurements can be linked after pasting. if let Some(e) = n.extras_if_any() @@ -1927,6 +1981,48 @@ impl Document { } impl Document { + /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. + pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> { + im.pixels.as_deref().or_else(|| self.images.get(&im.key)) + } + + /// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art + /// made outside a document) into [`Self::images`]: under the image's key, or a free one when + /// the key shows other pixels. + pub fn adopt_pixels(&mut self, n: &mut Node) { + // Only what holds such pixels is copied out of the art it shares. + fn holds(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some()) + || n.extras().mask.as_ref().is_some_and(|m| holds(&m.art)) + || n.children().into_iter().flatten().any(|c| holds(c)) + } + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + && holds(&m.art) + { + self.adopt_pixels(Arc::make_mut(&mut m.art)); + } + for c in n.children_mut().into_iter().flatten() { + if holds(c) { + self.adopt_pixels(Arc::make_mut(c)); + } + } + } + + fn adopt_image_pixels(&mut self, im: &mut ImageObject) { + let Some(blob) = im.pixels.take() else { return }; + let mut key = im.key.clone(); + let mut i = 1; + while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) { + i += 1; + key = format!("{}-{i}", im.key); + } + self.images.entry(key.clone()).or_insert_with(|| (*blob).clone()); + im.key = key; + } + /// Saved selections as a file gives them, made safe to list and recall: at most /// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document /// has, each once. (An id it doesn't have would select whatever object takes that id later.) @@ -2276,6 +2372,8 @@ mod tests { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); let rect = |d: &mut Document, r: Rect, appearance: Appearance| { diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 7abcec7c1..8632578c2 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -377,6 +377,7 @@ mod tests { xf: Affine::IDENTITY, link, placement: Default::default(), + pixels: None, })), ), ) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index fafa2fc98..b434245cc 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1476,6 +1476,9 @@ pub struct Hooks<'a> { pub appearance: Outliner<'a>, /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). pub pattern: Option>, + /// The document whose symbols' art counts toward the instance budget of a Repeat of their + /// instances ([`crate::RepeatSpec::expand`]). + pub symbols: Option<&'a crate::Document>, } impl<'a> From> for Hooks<'a> { @@ -1792,7 +1795,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { NodeKind::Blend { children, spec } => blend_expand(children, spec), NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), - NodeKind::Repeat(r) => r.expand(), + NodeKind::Repeat(r) => r.expand(hooks.symbols), NodeKind::PlacedDocument(p) => p.art(n), NodeKind::Intertwine { children } => crate::intertwine::expand(children), _ => vec![n.clone()], diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 7f7f34022..f9a6a11fd 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -433,6 +433,11 @@ pub struct ImageObject { /// Links panel → Placement Options: how a file read again takes this image's place. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub placement: crate::links::PlacementOptions, + /// The pixels of an image in art made outside a document (type's outlines, a glyph's art), + /// which has no [`crate::Document::images`] to keep them in. A document takes them in + /// ([`crate::Document::adopt_pixels`]): no image in one holds its own. + #[serde(skip)] + pub pixels: Option>, } /// Written with its `type` first, then its fields (an internally tagged enum); read by @@ -1227,7 +1232,7 @@ impl Node { NodeKind::Mesh(m) => m.bounds(), // Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the // copies out by the source's bounds measured without the document. - NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?), + NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())), NodeKind::PlacedDocument(p) => Some(p.bounds()), // The members drawn over the others are drawn where they are. NodeKind::Intertwine { children } => union(children), @@ -2543,7 +2548,8 @@ mod tests { /// A group holding a path, a compound, an image and a nested group, as a file writes it. fn nested_group() -> Node { let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let image = + ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index fdab92861..da310eb65 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -11,12 +11,15 @@ //! **Repeats.** A [`RepeatSpec`] holds source art and a radial / grid / mirror arrangement; the //! instances are the source under [`repeat_transforms`] (evaluated on demand like blends). +use std::cell::RefCell; +use std::collections::HashMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_color::Paint; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use crate::Document; use crate::appearance::AppearanceItem; use crate::node::{Node, NodeId, NodeKind}; @@ -519,17 +522,51 @@ pub struct RepeatSpec { /// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps. const MAX_REPEAT_INSTANCES: usize = 250_000; -/// Instances the Repeats in `nodes` make in all (0 when there are none). -fn nested_instances(nodes: &[Arc]) -> usize { - let mut sum = 0usize; - for n in nodes { - sum = sum.saturating_add(match (&n.kind, n.children()) { - (NodeKind::Repeat(r), _) => r.instances_in_all(), - (_, Some(c)) => nested_instances(c), - _ => 0, - }); +/// Counts the instances Repeats make in all, following symbol instances into their symbol's art +/// in `doc`: each symbol's art is counted once, and a missing symbol, or an instance of a symbol +/// inside its own art, counts as none. +/// +/// Limitation: symbols that show each other (A's art holds an instance of B and B's holds one of +/// A) are counted along the first path that reaches them. While A is counted, the instance of A +/// in B's art counts as none, and B keeps that count; had B been counted first, A's count would +/// include B's Repeats and B's would not. So within one count, what such symbols add can depend on +/// which of them came first. A symbol whose art reaches no such loop always counts the same. +struct NestedCount<'a> { + doc: Option<&'a Document>, + /// The counts made so far, by symbol name (`None` while that symbol's art is being counted). + symbols: RefCell>>, +} + +impl<'a> NestedCount<'a> { + fn new(doc: Option<&'a Document>) -> Self { + Self { doc, symbols: RefCell::default() } + } + + /// Instances the Repeats in the art of symbol `name` make in all. + fn symbol(&self, name: &str) -> usize { + if let Some(known) = self.symbols.borrow().get(name) { + return known.unwrap_or(0); + } + let Some(sym) = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == name)) else { return 0 }; + self.symbols.borrow_mut().insert(name.to_owned(), None); + let n = self.nodes(std::slice::from_ref(&sym.art)); + self.symbols.borrow_mut().insert(name.to_owned(), Some(n)); + n + } + + /// Instances the Repeats in `nodes` make in all (0 when there are none). + fn nodes(&self, nodes: &[Arc]) -> usize { + let mut sum = 0usize; + for n in nodes { + sum = sum.saturating_add(match (&n.kind, n.children()) { + (NodeKind::Repeat(r), _) => r.instances_in_all(self), + (NodeKind::SymbolInstance { symbol, .. }, _) => self.symbol(symbol), + (_, Some(c)) => self.nodes(c), + _ => 0, + }); + } + sum } - sum } /// Reflection across the line through `c` with direction angle `deg`. @@ -592,11 +629,13 @@ impl RepeatSpec { pub fn source_bounds(&self) -> Option { crate::live::nodes_bounds(&self.source) } - pub fn transforms(&self) -> Vec { + /// Where the instances go, counting toward the budget the Repeats in the art of the symbols + /// of `doc` that instances in the source show (none without a document). + pub fn transforms(&self, doc: Option<&Document>) -> Vec { let mut v = repeat_transforms(&self.kind, self.source_bounds()); // Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500 // grid of one is 62.5 billion instances): keep the first that fit the budget. - v.truncate(self.kept(nested_instances(&self.source))); + v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source))); v } /// How many of its instances this Repeat keeps when each makes `nested` instances of the @@ -604,19 +643,21 @@ impl RepeatSpec { fn kept(&self, nested: usize) -> usize { self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1) } - /// How many instances this Repeat draws ([`Self::transforms`]): as many as its options make, - /// fewer when the Repeats in its source would take it past [`MAX_REPEAT_INSTANCES`]. - pub fn instances(&self) -> usize { - self.kept(nested_instances(&self.source)) + /// How many instances this Repeat draws ([`Self::transforms`] for `doc`): as many as its + /// options make, fewer when the Repeats in its source would take it past + /// [`MAX_REPEAT_INSTANCES`]. + pub fn instances(&self, doc: Option<&Document>) -> usize { + self.kept(NestedCount::new(doc).nodes(&self.source)) } /// Instances made in all, those of the Repeats nested in the source included. - fn instances_in_all(&self) -> usize { - let nested = nested_instances(&self.source); + fn instances_in_all(&self, count: &NestedCount) -> usize { + let nested = count.nodes(&self.source); self.kept(nested).saturating_mul(nested.max(1)) } - /// Evaluated instances: one group of the (transformed) source per instance (ids 0). - pub fn expand(&self) -> Vec { - self.transforms() + /// Evaluated instances: one group of the (transformed) source per instance (ids 0), as many + /// as [`Self::transforms`] keeps for `doc`. + pub fn expand(&self, doc: Option<&Document>) -> Vec { + self.transforms(doc) .into_iter() .map(|m| { let children = self @@ -633,14 +674,14 @@ impl RepeatSpec { }) .collect() } - /// Geometric bounds of all instances. - pub fn bounds(&self) -> Option { - self.copies_bounds(self.source_bounds()?) + /// Geometric bounds of all instances ([`Self::transforms`] for `doc`). + pub fn bounds(&self, doc: Option<&Document>) -> Option { + self.copies_bounds(self.source_bounds()?, doc) } /// The bounds of all instances of a source measuring `source`, each placed by its transform - /// ([`Self::transforms`]). - pub(crate) fn copies_bounds(&self, source: Rect) -> Option { - self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source)))) + /// ([`Self::transforms`] for `doc`). + pub(crate) fn copies_bounds(&self, source: Rect, doc: Option<&Document>) -> Option { + self.transforms(doc).into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source)))) } /// Apply a document transform (source + arrangement parameters). pub fn transform(&mut self, a: Affine, scaling: impl Into) { @@ -864,11 +905,11 @@ mod tests { let r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0)); let RepeatKind::Radial { center, .. } = r.kind else { panic!() }; assert!((center - Point::new(0.0, -0.0)).hypot() < 1e-9, "{center:?}"); - let cs: Vec = r.transforms().iter().map(|m| *m * Point::new(0.0, -100.0)).collect(); + let cs: Vec = r.transforms(None).iter().map(|m| *m * Point::new(0.0, -100.0)).collect(); assert!((cs[0] - Point::new(0.0, -100.0)).hypot() < 1e-9); assert!((cs[1] - Point::new(100.0, 0.0)).hypot() < 1e-9, "{cs:?}"); assert!((cs[2] - Point::new(0.0, 100.0)).hypot() < 1e-9); - assert_eq!(r.expand().len(), 4); + assert_eq!(r.expand(None).len(), 4); } #[test] @@ -877,18 +918,18 @@ mod tests { if let RepeatKind::Radial { radius, .. } = &mut r.kind { *radius = 50.0; } - let m = r.transforms()[0]; + let m = r.transforms(None)[0]; assert!((m * Point::new(0.0, -100.0) - Point::new(0.0, -50.0)).hypot() < 1e-9); } #[test] fn grid_repeat_transforms() { let g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 5.0, 2.0); - let t = g.transforms(); + let t = g.transforms(None); assert_eq!(t.len(), 9); assert!((t[1] * Point::ZERO - Point::new(15.0, 0.0)).hypot() < 1e-9); assert!((t[3] * Point::ZERO - Point::new(0.0, 12.0)).hypot() < 1e-9); - assert_eq!(g.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 34.0))); + assert_eq!(g.bounds(None), Some(Rect::new(0.0, 0.0, 40.0, 34.0))); } #[test] @@ -897,7 +938,7 @@ mod tests { if let RepeatKind::Grid { flip_cols, .. } = &mut g.kind { *flip_cols = true; } - let t = g.transforms(); + let t = g.transforms(None); // Column 1 is mirrored about the source centre, then moved one tile right. assert!((t[1] * Point::new(0.0, 0.0) - Point::new(20.0, 0.0)).hypot() < 1e-9); } @@ -905,10 +946,10 @@ mod tests { #[test] fn mirror_transforms() { let m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0); - let t = m.transforms(); + let t = m.transforms(None); assert_eq!(t.len(), 2); assert!((t[1] * Point::new(0.0, 3.0) - Point::new(30.0, 3.0)).hypot() < 1e-9); - let b = m.bounds().unwrap(); + let b = m.bounds(None).unwrap(); assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9 && (b.y1 - 10.0).abs() < 1e-9, "{b:?}"); // Horizontal axis. let h = reflect_about(Point::new(0.0, 10.0), 0.0); @@ -918,9 +959,9 @@ mod tests { #[test] fn repeat_transform_moves_arrangement() { let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0)); - let before = r.bounds().unwrap(); + let before = r.bounds(None).unwrap(); r.transform(Affine::translate((50.0, 0.0)), false); - let after = r.bounds().unwrap(); + let after = r.bounds(None).unwrap(); assert!((after.x0 - before.x0 - 50.0).abs() < 1e-9); let mut m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0); m.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); @@ -933,7 +974,7 @@ mod tests { // Instances made in all, counted from the transforms without drawing any. fn made(r: &RepeatSpec) -> usize { let each: usize = r.source.iter().map(|s| if let NodeKind::Repeat(n) = &s.kind { made(n) } else { 1 }).sum(); - r.transforms().len().saturating_mul(each) + r.transforms(None).len().saturating_mul(each) } let grid = |source: Arc| { let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0); @@ -950,7 +991,7 @@ mod tests { // A 500 × 500 grid of it made 62.5 billion. let outer = grid(repeat(inner.clone())); assert!(made(&outer) <= 250_000, "{} instances", made(&outer)); - assert_eq!(outer.expand().len(), outer.transforms().len()); + assert_eq!(outer.expand(None).len(), outer.transforms(None).len()); assert!(made(&RepeatSpec::mirror(vec![repeat(inner)], 90.0, 5.0)) <= 250_000); // Small nests are left as they are. let small = RepeatSpec::mirror(vec![repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 1.0))], 90.0, 5.0); diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 8c6b848bf..a94edf42a 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -146,7 +146,7 @@ impl PlacedDocument { pub fn preview_image(&self, bytes: &[u8]) -> Option { let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?; let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64); - Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() }) + Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default(), pixels: None }) } } @@ -288,11 +288,23 @@ pub fn set_file_reader(f: FileReader) { let _ = FILE_READER.set(f); } +/// Whether the caller may read the file at a path (`true`: it may). The engine registers one with +/// [`set_read_check`]: the cache below is shared by every caller, and a cached file is handed out +/// only to a caller that may read the file it came from. +pub type ReadCheck = fn(&str) -> bool; + +static READ_CHECK: OnceLock = OnceLock::new(); + +/// Register how a caller's right to read a file is checked (the first registration stays). +pub fn set_read_check(f: ReadCheck) { + let _ = READ_CHECK.set(f); +} + /// The most bytes of files read again kept. const FULL_BYTES: usize = 512 << 20; -/// Files read again, by key, the most recently read last. -type FullFiles = Vec<(String, Arc>)>; +/// Files read again, by key, with the path each was read from, the most recently read last. +type FullFiles = Vec<(String, String, Arc>)>; static FULL: Mutex = Mutex::new(Vec::new()); @@ -307,7 +319,11 @@ pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option>)> = + full().iter().rev().filter(|(k, _, _)| *k == p.key).map(|(_, path, b)| (path.clone(), b.clone())).collect(); + candidates.into_iter().find(|(path, _)| allowed(path)).map(|(_, b)| b) } /// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None` @@ -318,10 +334,10 @@ pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option>> } let b = Arc::new(FILE_READER.get()?(p)?); let mut c = full(); - c.push((p.key.clone(), b.clone())); - let mut total: usize = c.iter().map(|(_, b)| b.len()).sum(); + c.push((p.key.clone(), p.link.path.clone(), b.clone())); + let mut total: usize = c.iter().map(|(_, _, b)| b.len()).sum(); while total > FULL_BYTES && c.len() > 1 { - total = total.saturating_sub(c.remove(0).1.len()); + total = total.saturating_sub(c.remove(0).2.len()); } Some(b) } @@ -444,7 +460,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc>) -> Option Result, String> { if t.is_empty() { return Ok(open); } - t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) + t.bytes() + .all(|b| b.is_ascii_digit()) + .then(|| t.parse().ok()) + .flatten() + .filter(|n| (1..=count).contains(n)) + .ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) }; let mut out = vec![]; for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { @@ -35,3 +40,16 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { } Ok(out) } + +#[cfg(test)] +mod tests { + use super::parse_range; + + #[test] + fn sign_is_not_part_of_a_page_number() { + assert!(parse_range("+2", 5).is_err()); + assert!(parse_range("1-+3", 5).is_err()); + assert!(parse_range("-+2", 5).is_err()); + assert_eq!(parse_range("1-3, 5", 5), Ok(vec![0, 1, 2, 4])); + } +} diff --git a/crates/doc/src/recolor.rs b/crates/doc/src/recolor.rs index 715c1aa9a..a5a878710 100644 --- a/crates/doc/src/recolor.rs +++ b/crates/doc/src/recolor.rs @@ -65,7 +65,9 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi } changed += ch as usize; } - NodeKind::Image(im) if reach.images && im.link.is_none() => { + // An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its + // colours, as the glyph does in type. + NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => { if let Some(k) = v.image(&im.key) { im.key = k; changed += 1; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index af2ead527..275eea4bb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -481,6 +481,80 @@ fn default_align_on() -> char { /// Distance between default tab stops when no explicit stop applies (½ inch). pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; +/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Bullet { + /// A filled dot (•). + #[default] + Dot, + /// A dash (–). + Dash, + /// A hollow circle (◦). + Circle, + /// A filled square (▪). + Square, +} + +impl Bullet { + /// The character the layout draws for the bullet. + pub fn character(self) -> char { + match self { + Bullet::Dot => '\u{2022}', + Bullet::Dash => '\u{2013}', + Bullet::Circle => '\u{25E6}', + Bullet::Square => '\u{25AA}', + } + } +} + +/// 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 +/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after +/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a +/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves +/// out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaList { + /// The bullet each paragraph of the list starts with. + pub bullet: Bullet, +} + +impl ParaList { + /// A bulleted list with `bullet`. + pub fn bulleted(bullet: Bullet) -> Self { + Self { bullet } + } + + /// The characters of the marker the layout draws before the paragraph. + pub fn marker(&self) -> String { + self.bullet.character().to_string() + } + + /// The style the marker is set in: `first` (the style of the paragraph's first character) + /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, + /// tate-chu-yoko, underline and strikethrough. + pub fn marker_style(first: &CharStyle) -> CharStyle { + CharStyle { + baseline_shift: 0.0, + rotation: 0.0, + position: CharPosition::Normal, + tracking: 0.0, + kerning: None, + tate_chu_yoko: false, + underline: false, + strikethrough: false, + ..first.clone() + } + } +} + +/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the +/// marker hangs in the first line's 18 pt before the text. +pub const LIST_HANGING_INDENT: f64 = 18.0; + /// Paragraph composer (Paragraph panel menu): how lines are broken. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -674,6 +748,9 @@ pub struct ParaStyle { pub composer: Composer, /// Tab stops (Tabs panel), sorted by position. pub tabs: Vec, + /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not + /// in a list. + pub list: Option, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. pub style_name: Option, /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made @@ -762,6 +839,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "Vec::is_empty")] tabs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + list: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] style_name: Option, #[serde(default, skip_serializing_if = "Mojikumi::is_none")] mojikumi: Mojikumi, @@ -798,6 +877,7 @@ impl From for ParaStyleFile { justification, composer, tabs, + list, style_name, mojikumi, direction, @@ -821,6 +901,7 @@ impl From for ParaStyleFile { justification, composer, tabs, + list, style_name, mojikumi, direction, @@ -848,6 +929,7 @@ impl From for ParaStyle { hyphenation, composer, tabs, + list, style_name, mojikumi, direction, @@ -870,6 +952,7 @@ impl From for ParaStyle { justification, composer, tabs, + list, style_name, mojikumi, direction, diff --git a/crates/doc/src/trace.rs b/crates/doc/src/trace.rs index 3f02af716..042d6b3ad 100644 --- a/crates/doc/src/trace.rs +++ b/crates/doc/src/trace.rs @@ -103,6 +103,7 @@ mod tests { xf: Affine::IDENTITY, link: None, placement: Default::default(), + pixels: None, })), ); source.visible = false; diff --git a/crates/doc/src/xml_id.rs b/crates/doc/src/xml_id.rs index db34395d6..ba454de01 100644 --- a/crates/doc/src/xml_id.rs +++ b/crates/doc/src/xml_id.rs @@ -73,7 +73,7 @@ fn escaped(s: &str) -> Option<(char, usize)> { let body = s.strip_prefix("_x")?; let end = body.find('_')?; let hex = body.get(..end)?; - if hex.is_empty() || hex.len() > 6 { + if hex.is_empty() || hex.len() > 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?; @@ -113,6 +113,12 @@ mod tests { assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid("")); } + #[test] + fn sign_is_not_a_hex_digit_in_escapes() { + assert_eq!(to_object_name("a_x+41_b"), "a x+41 b"); + assert_eq!(to_object_name("a_x-41_b"), "a x-41 b"); + } + #[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/adjust.rs b/crates/effects/src/adjust.rs index d4f156d39..faa7a5a7c 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -415,7 +415,7 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()); - art = crate::reshape::as_art(n, symbol.as_deref())?; + art = crate::reshape::as_art(Some(d), n, symbol.as_deref())?; &art } _ => n, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 9cb35674a..496946e30 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -104,7 +104,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return Some(bake_pieces(d, m)); } if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { - let m = evaluate_container(&m).unwrap_or(m); + let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } // Type, images, symbol instances and live objects: reshaped through their outlines, and so are @@ -119,21 +119,21 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - let m = reshape(n, symbol.as_deref())?; + let m = reshape(Some(d), n, symbol.as_deref())?; return Some(bake_node(d, &m).unwrap_or(m)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning // every copy and its strokes and patterns can differ from the others'. if crate::has_transform_art(n) - && let Some(m) = reshape(n, None) + && let Some(m) = reshape(Some(d), n, None) { return Some(bake_pieces(d, m)); } // Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that // Transform effects only move stays type). The new pieces (results, outlines, copies, the // fills' and strokes' art) get ids of their own. - if let Some(m) = container_keeping_type(n).or_else(|| evaluate_container(n)) { + if let Some(m) = container_keeping_type(d, n).or_else(|| evaluate_container(Some(d), n)) { return Some(bake_pieces(d, m)); } if let Some(ch) = n.children() { @@ -324,7 +324,7 @@ fn moved_type_group(n: &Node) -> Option { /// objects or inside groups with Pathfinder or geometry effects or fills and strokes that paint, is /// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of /// them. `None` when no type stays type. -fn container_keeping_type(n: &Node) -> Option { +fn container_keeping_type(d: &Document, n: &Node) -> Option { fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; @@ -349,7 +349,7 @@ fn container_keeping_type(n: &Node) -> Option { } } None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - None => as_art(c, None), + None => as_art(None, c, None), }; if let Some(o) = art { *c = Arc::new(o); @@ -366,8 +366,7 @@ fn container_keeping_type(n: &Node) -> Option { if !text { return None; } - let hook: &dyn Fn(&Node) -> Option = &outline_text; - let mut m = vectorcraft_doc::live::expand_deep(n, Some(hook)); + let mut m = crate::reshape::evaluated(Some(d), n); let mut kept = false; keep(&mut m, &moves, false, &mut kept); if !kept { @@ -455,7 +454,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, & /// effects are dropped. `None` for other kinds of objects. pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option { if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) { - let m = evaluate_container(&m).unwrap_or(m); + let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } let (g, rule, ctx, mut m) = leaf_base(n)?; @@ -755,7 +754,7 @@ mod tests { baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id)); // The renderer and Expand Appearance still draw it as outlines. let mut text = 0; - evaluate_container(&g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); + evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); assert_eq!(text, 0, "{params}"); } // Stacked effects that only move: every copy of a copy. diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 5196b4fcc..5463857ed 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -4,7 +4,8 @@ //! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect, //! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its //! top leaf — as in the Pathfinder panel. The result replaces the children at render time; each -//! result keeps the appearance of the object whose paint it takes. +//! result keeps the appearance of the object whose paint it takes. Trap ([`crate::trap`]) keeps the +//! children and adds its traps above them. //! //! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes //! paint every member's geometry, below or above the members as its contents slot says @@ -15,7 +16,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_pathops::{self as po, PathfinderOp}; @@ -197,6 +198,25 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option] = group.children()?; let mut stack: Vec> = source.to_vec(); for e in group.appearance.effects.iter().filter(|e| e.visible) { + if e.id == crate::trap::TRAP { + let mut lv = vec![]; + for c in &stack { + leaves(c, hook, &mut lv); + } + if !within_budget(&lv) { + return Some(source.to_vec()); + } + let (ids, srcs): (Vec, Vec) = lv + .iter() + .filter_map(|l| { + let (path, rule) = geometry(l)?; + Some((l.id, crate::trap::TrapSource { path, rule, fill: l.appearance.fill_paint() })) + }) + .unzip(); + let made = crate::trap::traps(&srcs, &crate::trap::TrapOptions::from_params(&e.params)); + stack.extend(made.into_iter().map(|p| Arc::new(crate::trap::trap_node(ids.get(p.from).copied().unwrap_or(NodeId(0)), p)))); + continue; + } let Some(op) = op_of(&e.id) else { continue }; let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); @@ -346,9 +366,10 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live /// objects evaluated; the members of a clip group or clipping layer without its clipping path. -/// Images, symbol instances and guides add nothing. -pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> { - fn push(n: &Node, out: &mut Vec<(PathData, FillRule)>) { +/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of +/// `doc`'s symbols that its instances show toward its instance budget. +pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { + fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { if !n.visible { return; } @@ -360,19 +381,19 @@ pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> { out.push((PathData::from_bezpath(&all).transformed(t.xf), FillRule::NonZero)); } } - NodeKind::Group { .. } | NodeKind::Layer { .. } => members(n, out), - _ if vectorcraft_doc::live::is_live(n) => members(&crate::reshape::outlined_members(n), out), + NodeKind::Group { .. } | NodeKind::Layer { .. } => members(doc, n, out), + _ if vectorcraft_doc::live::is_live(n) => members(doc, &crate::reshape::outlined_members(doc, n), out), _ => {} } } - fn members(n: &Node, out: &mut Vec<(PathData, FillRule)>) { + fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { let clip = usize::from(n.clips()); for c in n.children().into_iter().flatten().skip(clip) { - push(c, out); + push(doc, c, out); } } let mut out = vec![]; - members(n, &mut out); + members(doc, n, &mut out); out } @@ -414,8 +435,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], /// reshaping all its members as one piece (the whole art's bounds as their reference box), and its /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below /// the members (after a clipping path) or above them, as its contents slot says. Its -/// transparency, opacity mask and raster effects stay. `None` when it has none of these. -pub fn evaluate_container(n: &Node) -> Option { +/// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live +/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]). +pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { if !is_container(n) { return None; } @@ -434,12 +456,12 @@ pub fn evaluate_container(n: &Node) -> Option { } } if !fx.is_empty() { - m = crate::reshape::outlined_members(&m); + m = crate::reshape::outlined_members(doc, &m); crate::reshape::reshape_leaves(&mut m, &fx); } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { - let shapes = member_shapes(&m); + let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); let art = |items: &[AppearanceItem]| -> Vec> { items.iter().filter_map(|i| item_art(n.id, i, &shapes, bounds)).map(Arc::new).collect() }; @@ -517,6 +539,25 @@ mod tests { ) } + /// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter + /// colour inside the darker one along the edge where they meet. + #[test] + fn trap_effect_adds_an_overprinting_strip_above_the_members() { + let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0); + let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); + let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); + g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 3, "both squares and one trap"); + assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); + let b = out[2].geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}"); + let fill = out[2].appearance.fill().unwrap(); + assert!(fill.overprint); + assert_eq!(fill.paint.color(), Some(yellow)); + } + #[test] fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() { let mut g = two_overlapping_cmyk_squares(); @@ -624,7 +665,7 @@ mod tests { fn a_random_transform_on_a_group_moves_its_members_together() { let mut g = group_with("distort.transform"); g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true}); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap(); let (a, b) = (corner(0), corner(1)); assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}"); @@ -640,7 +681,7 @@ mod tests { style(&mut t); let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); (*art.children().unwrap()[1]).clone() } @@ -698,7 +739,7 @@ mod tests { let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]); let mut g = Node::group(NodeId(10), vec![square, t]); g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let members = art.children().unwrap(); let after = start(&members[1].children().unwrap()[0]); assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); @@ -718,7 +759,7 @@ mod tests { let mut g = Node::group(NodeId(10), vec![square]); let params = serde_json::json!({"moveH": 30, "copies": 2}); g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); assert_eq!(copies.len(), 3); for (i, c) in copies.iter().enumerate() { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 23d839726..ce1b1d58d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -51,6 +51,7 @@ mod revolve; mod solids; pub mod stroke; mod stylize; +pub mod trap; mod util; mod warp; @@ -89,6 +90,7 @@ pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, validate_three_d, view_options, }; +pub use trap::TRAP; pub use warp::{WarpStyle, warp_point}; /// Catalogue entry for one effect. @@ -169,6 +171,7 @@ fn lengths_of(id: &str) -> Lengths { "sketch.notePaper" => always(&["relief"]), "sketch.tornEdges" => always(&["smoothness"]), "sketch.waterPaper" => always(&["fiberLength"]), + TRAP => always(&["thickness", "precision"]), RASTERIZE => always(&["addAround"]), _ => Lengths::default(), } @@ -850,6 +853,7 @@ pub fn effect_catalog() -> Vec { v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); } } + v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); v.push(g( CROP_MARKS, "Crop Marks", diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index 7d03068c0..aad8d8edd 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -37,7 +37,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option { fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { let outline = |n: &Node| outline(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); - f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles) }) + f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) } /// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 87335670c..8885c9def 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -932,22 +932,41 @@ pub fn dilate_plane(a: &mut Vec, w: usize, h: usize, radius: f64) { /// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest /// value within `whole`, blended by `frac` towards the largest within `whole + 1`. fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) { + let (mut inner, mut ring) = (vec![0.0f32; w], vec![0.0f32; w]); for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) { - for (x, o) in out.iter_mut().enumerate() { - let lo = x.saturating_sub(whole + 1); - let hi = (x + whole + 1).min(w - 1); - let (mut inner, mut ring) = (row[x], row[x]); - for (j, v) in row[lo..=hi].iter().enumerate() { - if (lo + j).abs_diff(x) <= whole { - inner = inner.max(*v); - } - ring = ring.max(*v); - } - *o = inner + (ring - inner) * frac; + window_max(row, whole, &mut inner); + window_max(row, whole + 1, &mut ring); + for ((o, i), r) in out.iter_mut().zip(&inner).zip(&ring) { + *o = i + (r - i) * frac; } } } +/// For each `x`, the largest of `row[x - radius..=x + radius]` (cut at the row's ends) into `out`, +/// in time linear in the row (a sliding window, not a scan of the window for each `x`). +fn window_max(row: &[f32], radius: usize, out: &mut [f32]) { + use std::collections::VecDeque; + // Indices of the window's values that can still be its largest, falling in value. + let mut keep: VecDeque = VecDeque::new(); + let mut next = 0; + for (x, o) in out.iter_mut().enumerate().take(row.len()) { + let hi = x.saturating_add(radius).min(row.len().saturating_sub(1)); + while next <= hi { + let v = row[next]; + while keep.back().is_some_and(|&b| row[b] <= v) { + keep.pop_back(); + } + keep.push_back(next); + next += 1; + } + let lo = x.saturating_sub(radius); + while keep.front().is_some_and(|&f| f < lo) { + keep.pop_front(); + } + *o = keep.front().map_or(row[x], |&f| row[f]); + } +} + /// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones. pub(super) type Px16 = [u16; 4]; @@ -1061,3 +1080,22 @@ mod tests; mod tests_paint; #[cfg(test)] mod tests_sketch; + +#[cfg(test)] +mod window_max_tests { + use super::window_max; + + #[test] + fn the_sliding_maximum_matches_a_scan_of_each_window() { + let row: Vec = (0..40u32).map(|i| ((i * 7919) % 31) as f32).collect(); + for radius in [0, 1, 2, 5, 39, 100] { + let mut out = vec![0.0; row.len()]; + window_max(&row, radius, &mut out); + for (x, got) in out.iter().enumerate() { + let (lo, hi) = (x.saturating_sub(radius), (x + radius).min(row.len() - 1)); + let want = row[lo..=hi].iter().copied().fold(f32::MIN, f32::max); + assert_eq!(*got, want, "radius {radius} at {x}"); + } + } + } +} diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index a2f0daef6..a1b8eed98 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -186,7 +186,7 @@ impl<'s, 'd> Drawn<'s, 'd> { let inner = || n.children().is_some_and(|ch| ch.iter().any(|c| self.draws_geometry(c))); let painted = || has_own_paint(n) && inner(); match &n.kind { - NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(n), + NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(Some(self.symbols.document()), n), NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n), NodeKind::SymbolInstance { symbol, .. } => { if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) { @@ -196,9 +196,10 @@ impl<'s, 'd> Drawn<'s, 'd> { self.left.set(left); let art = self.symbols.measure_art(symbol, Arc::clone)?; // An instance in perspective is projected by `reshape`. - reshape(n, Some(&*art)).or_else(|| outline_art(n, Some(&*art))) + let doc = Some(self.symbols.document()); + reshape(doc, n, Some(&*art)).or_else(|| outline_art(doc, n, Some(&*art))) } - _ if reshapes(n) => reshape(n, None), + _ if reshapes(n) => reshape(Some(self.symbols.document()), n, None), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) if inner() => { Some(expanded_live_group(Some(self.symbols.document()), n)) } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index b27587d89..0deed993a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -13,7 +13,8 @@ use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind}; +use vectorcraft_doc::live::Hooks; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params}; @@ -118,7 +119,7 @@ pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) { return vec![]; } - let Some(art) = reshape(n, None) else { return vec![] }; + let Some(art) = reshape(None, n, None) else { return vec![] }; let mut out = vec![]; art.walk(&mut |c| { let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return }; @@ -140,12 +141,14 @@ pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { /// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one /// path per run, and all of them as one. pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath) { - let (runs, all, _) = laid_out(t); + let (runs, all, ..) = laid_out(t); (runs, all) } -/// [`glyph_outlines`] and the bounds of `t`'s layout in text space. -fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath, Rect) { +/// [`glyph_outlines`], the bounds of `t`'s layout in text space, and its colour glyphs, which have +/// no outlines, placed in the document with their runs: bitmap glyphs (emoji) as images holding +/// their pixels, then COLR and SVG glyphs as their art, as the renderer paints them. +fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath, Rect, Vec<(usize, Node)>) { let db = vectorcraft_text::FontDb::global(); let layout = vectorcraft_text::layout(db, t); let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; @@ -163,34 +166,37 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::Bez } all.extend(bar.iter()); } - (runs, all, layout.bounds) + let images = layout.glyphs.iter().filter_map(|g| Some((g.run, g.placed_image_node(t.xf)?))); + let colours = images.chain(layout.glyphs.iter().filter_map(|g| Some((g.run, g.placed_colour(t.xf)?)))).collect(); + (runs, all, layout.bounds, colours) } /// Type as glyph outlines in document space: one path per run with the run's fill and stroke, -/// and (when the object has its own fills/strokes) the whole outline with the object's items below -/// the characters under them and the others over them, as the renderer paints them. Each run's -/// path is placed by the type's transform, which also scales its stroke weights and dashes and -/// transforms its pattern tiles; its unplaced gradients are first fixed to the layout's bounds in -/// text space. The object's own unplaced gradients are fixed to the bounding box of those bounds -/// in the document. The group is marked as type's outlines +/// over its colour glyphs (images, art), and (when the object has its own fills/strokes) the whole +/// outline with the object's items below the characters under them and the others over them, as +/// the renderer paints them. Each run's path is placed by the type's transform, which also scales its stroke +/// weights and dashes and transforms its pattern tiles; its unplaced gradients are first fixed to +/// the layout's bounds in text space. The object's own unplaced gradients are fixed to the +/// bounding box of those bounds in the document. The group is marked as type's outlines /// ([`vectorcraft_doc::NodeExtras::type_outlines`]). pub fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; - let (runs, all, bounds) = laid_out(t); + let (runs, all, bounds, colours) = laid_out(t); let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf); let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() }; - let mut children: Vec> = runs - .iter() - .zip(&t.runs) - .filter(|(bp, _)| !bp.elements().is_empty()) - .map(|(bp, run)| { - let mut ap = run.style.appearance(); - ap.pin_gradients(bounds); - let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap); - piece.transform(t.xf, scaling); - Arc::new(piece) - }) - .collect(); + let mut children: Vec> = vec![]; + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's fill, as the renderer paints them. + children.extend(colours.iter().filter(|(r, _)| *r == i).map(|(_, art)| Arc::new(with_id(art, n.id)))); + if bp.elements().is_empty() { + continue; + } + let mut ap = run.style.appearance(); + ap.pin_gradients(bounds); + let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap); + piece.transform(t.xf, scaling); + children.push(Arc::new(piece)); + } if !n.appearance.items.is_empty() && !all.elements().is_empty() { let (below, above) = n.appearance.split_contents(); let tb = t.xf.transform_rect_bbox(bounds); @@ -211,12 +217,23 @@ pub fn outline_text(n: &Node) -> Option { Some(g) } +/// `art` with `id` for it and all its members, as the other pieces of outlined type have the type's +/// (Expand Appearance gives them ids of their own). +fn with_id(art: &Node, id: NodeId) -> Node { + let mut art = art.clone(); + art.id = id; + for c in art.children_mut().into_iter().flatten() { + *c = Arc::new(with_id(c, id)); + } + art +} + /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform. `None` for other kinds (images, paths, containers). -pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { - let hook: &dyn Fn(&Node) -> Option = &outline_text; +/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts +/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. +pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { let mut art = match &n.kind { NodeKind::Text(_) => outline_text(n)?, NodeKind::SymbolInstance { xf, .. } => { @@ -234,7 +251,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { one.appearance.effects.clear(); Node::group(n.id, vec![Arc::new(one)]) } - _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), + _ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n), _ => return None, }; outline_nested_text(&mut art, false); @@ -248,6 +265,14 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { Some(art) } +/// `n` with every live object in it evaluated, type inside them outlined ([`outline_text`]). The +/// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show +/// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]). +pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node { + let hook: &dyn Fn(&Node) -> Option = &outline_text; + vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() }) +} + /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes /// its plain outlines ([`outline_text`]) with its visibility, which is what @@ -259,7 +284,7 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { let clipping = clipping || (clips && i == 0); let art = match &c.kind { NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - NodeKind::Text(_) => as_art(c, None), + NodeKind::Text(_) => as_art(None, c, None), _ => None, }; if let Some(o) = art { @@ -279,22 +304,22 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective /// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, /// or has no art (a symbol instance without `symbol_art`). -pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { +pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) { return None; } - as_art(n, symbol_art) + as_art(doc, n, symbol_art) } /// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand /// Appearance: [`reshape`]d when it has /// geometry effects, else type with fills or strokes of its own outlined, or an image with fills /// or strokes of its own that paint a shape, in the same form. `None` when it has none of these. -pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { +pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own. if has_transform_art(n) { - return as_art(n, symbol_art); + return as_art(doc, n, symbol_art); } let own_paint = match n.kind { NodeKind::Text(_) => !n.appearance.items.is_empty(), @@ -303,11 +328,11 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) { return None; } - as_art(n, symbol_art) + as_art(doc, n, symbol_art) } /// [`reshape`] without its checks (no geometry effects: just the art). -pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { +pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -335,7 +360,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect()) } } - _ => outline_art(n, symbol_art)?, + _ => outline_art(doc, n, symbol_art)?, }; reshape_leaves(&mut art, &fx); // Type and symbols in perspective: the art projected onto their plane. @@ -679,9 +704,8 @@ fn placed_count(n: &Node) -> usize { /// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on /// it reach every member. -pub(crate) fn outlined_members(n: &Node) -> Node { - let hook: &dyn Fn(&Node) -> Option = &outline_text; - let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook)); +pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node { + let mut art = evaluated(doc, n); outline_nested_text(&mut art, false); art } diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index 2db14d10f..bdb5f8623 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -184,9 +184,14 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { row: u64, } let mut chains: Vec = vec![]; - let mut y = y0 + step / 2.0; let mut row = 0u64; - while y < y1 { + // Rows are counted, not summed: adding a step that is below the spacing of the numbers at `y` + // would never move it. + while row <= 2001 { + let y = y0 + step / 2.0 + row as f64 * step; + if y.partial_cmp(&y1) != Some(std::cmp::Ordering::Less) { + break; + } let mut xs: Vec = vec![]; for pl in &polys { for w in pl.windows(2) { @@ -225,7 +230,6 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { } } row += 1; - y += step; } let lines: Vec = chains .into_iter() diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 80d67eef6..ad66a2a97 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -262,6 +262,7 @@ fn stacked_transform_copies_stay_within_the_image_budget() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ) }; @@ -284,7 +285,7 @@ fn stacked_transform_copies_stay_within_the_image_budget() { assert_eq!(count(&art), 25); let mut g = Node::group(id, vec![Arc::new(image())]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000}))); - assert_eq!(count(&evaluate_container(&g).unwrap()), 1001); + assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001); } /// What `effects` make of `path` within `budget`. @@ -458,11 +459,12 @@ fn transform_on_a_group_moves_and_copies_its_images() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ); let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2}))); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let mut xs = vec![]; image_xs(&art, &mut xs); xs.sort_by(f64::total_cmp); @@ -479,11 +481,12 @@ fn transform_on_a_group_moves_and_copies_its_images() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ))], ); g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8}))); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let mut xs = vec![]; image_xs(&art, &mut xs); // The bounds are the image itself, so the bottom-right reference is its own: it halves in place. @@ -508,11 +511,11 @@ fn transform_on_a_group_moves_and_copies_its_symbol_instances() { }; let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2}))); - assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart"); + assert_eq!(xs(&evaluate_container(None, &g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart"); // Without copies the instance itself takes the transform, as a path does. let mut g = Node::group(id, vec![Arc::new(instance())]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70}))); - assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]); + assert_eq!(xs(&evaluate_container(None, &g).unwrap()), [75.0]); } /// Images and symbol instances share the copy budget: copies of one leave less room for the other. @@ -531,6 +534,7 @@ fn images_and_symbol_instances_share_the_copy_budget() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ); let instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); @@ -560,13 +564,14 @@ fn transform_copies_of_images_stop_at_the_reach_limit() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ); let mut g = Node::group(id, vec![Arc::new(image)]); // The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9. g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))); let mut xs = vec![]; - image_xs(&evaluate_container(&g).unwrap(), &mut xs); + image_xs(&evaluate_container(None, &g).unwrap(), &mut xs); assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach"); assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9)); } @@ -1061,3 +1066,43 @@ fn bench_catalog_lookup() { } eprintln!("10k lookups: {:?} ({n})", t.elapsed()); } + +#[test] +fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { + use std::sync::Arc; + use vectorcraft_doc::{Document, NodeId, RepeatKind, RepeatSpec, Symbol}; + let unit = || { + Arc::new(Node::path( + NodeId(0), + PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0)], true)), + Appearance::default(), + )) + }; + let grid = |source: Arc, n: u32| { + let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0); + if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind { + *rows = n; + *cols = n; + } + g + }; + let repeat = |r: RepeatSpec| Arc::new(Node::new(NodeId(0), NodeKind::Repeat(r))); + let instance = |name: &str| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })); + let mut d = Document::new(100.0, 100.0); + // A 500 × 500 grid as a symbol's art, directly and inside a group; a symbol holding itself. + d.symbols.push(Symbol { name: "Grid".into(), art: repeat(grid(unit(), 500)) }); + d.symbols.push(Symbol { name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); + d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); + d.symbols.push(Symbol { name: "Small".into(), art: repeat(grid(unit(), 3)) }); + // A 3 × 3 grid of an instance of each: each copy draws 250,000 instances, so one copy fits. + for name in ["Grid", "Grouped"] { + let outer = Node::new(NodeId(0), NodeKind::Repeat(grid(instance(name), 3))); + let kept = expand_live(Some(&d), &outer).len(); + assert!(kept <= 1, "{name}: {kept} copies of 250,000 instances"); + } + // A symbol holding itself counts no Repeats, and small nests keep every instance. + let looped = Node::new(NodeId(0), NodeKind::Repeat(grid(instance("Loop"), 3))); + assert_eq!(expand_live(Some(&d), &looped).len(), 9); + let small = Node::new(NodeId(0), NodeKind::Repeat(RepeatSpec::mirror(vec![instance("Small")], 90.0, 5.0))); + assert_eq!(expand_live(Some(&d), &small).len(), 2); +} diff --git a/crates/effects/src/tests_transformfx.rs b/crates/effects/src/tests_transformfx.rs index 39a76256c..a5fdd674c 100644 --- a/crates/effects/src/tests_transformfx.rs +++ b/crates/effects/src/tests_transformfx.rs @@ -90,7 +90,7 @@ fn transform_objects_off_maps_nothing_and_makes_no_copies() { fn scale_strokes_and_effects_scales_the_stroke_and_dash_with_the_object() { let n = stroked(json!({"scaleH": 50, "scaleV": 50, "strokes": true})); assert!(has_transform_art(&n)); - let art = reshape(&n, None).unwrap(); + let art = reshape(None, &n, None).unwrap(); let paths = leaves(&art); assert_eq!(paths.len(), 1); let (w, dash) = stroke_of(paths[0]); @@ -103,7 +103,7 @@ fn scale_strokes_and_effects_scales_the_stroke_and_dash_with_the_object() { #[test] fn each_copy_scales_its_stroke_once_more() { let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); - let art = reshape(&n, None).unwrap(); + let art = reshape(None, &n, None).unwrap(); let widths: Vec = leaves(&art).iter().map(|p| stroke_of(p).0).collect(); assert_eq!(widths, vec![2.0, 4.0, 8.0]); // Without the option the copies keep the original's weight. @@ -114,7 +114,7 @@ fn each_copy_scales_its_stroke_once_more() { #[test] fn transform_objects_off_keeps_the_geometry_and_scales_the_stroke_once() { let n = stroked(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true})); - let art = reshape(&n, None).unwrap(); + let art = reshape(None, &n, None).unwrap(); let paths = leaves(&art); assert_eq!(paths.len(), 1, "no copies"); assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); @@ -125,7 +125,7 @@ fn transform_objects_off_keeps_the_geometry_and_scales_the_stroke_once() { fn transform_patterns_moves_each_copys_pattern() { let n = patterned(json!({"moveH": 30, "copies": 2, "patterns": true})); assert!(has_transform_options(&n)); - let art = reshape(&n, None).unwrap(); + let art = reshape(None, &n, None).unwrap(); let xs: Vec = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); assert_eq!(xs, vec![0.0, 30.0, 60.0]); // Without it, every copy's pattern stays put (the tiles don't follow the copies). @@ -136,7 +136,7 @@ fn transform_patterns_moves_each_copys_pattern() { #[test] fn transform_patterns_with_objects_off_moves_only_the_pattern() { let n = patterned(json!({"rotate": 45, "objects": false, "patterns": true})); - let art = reshape(&n, None).unwrap(); + let art = reshape(None, &n, None).unwrap(); let paths = leaves(&art); assert_eq!(paths.len(), 1); assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); @@ -150,7 +150,7 @@ fn stroke_scaling_across_copies_stays_finite() { let mut n = Node::path(NodeId(9), shapes::line((0.0, 0.0).into(), (100.0, 0.0).into()), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); n.appearance.effects.push(transform(json!({"scaleH": 4000, "scaleV": 4000, "copies": 1000, "strokes": true}))); - if let Some(art) = reshape(&n, None) { + if let Some(art) = reshape(None, &n, None) { for p in leaves(&art) { let w = stroke_of(p).0; assert!(w.is_finite() && w <= 1.0e6, "{w}"); diff --git a/crates/effects/src/trap.rs b/crates/effects/src/trap.rs new file mode 100644 index 000000000..f60232bed --- /dev/null +++ b/crates/effects/src/trap.rs @@ -0,0 +1,469 @@ +//! Pathfinder › Trap, shared by the Pathfinder panel's Trap command (`object.pathfinder.trap`) and +//! the live effect Effect › Pathfinder › Trap ([`TRAP`]). +//! +//! Where two colours printed from separate plates meet, a press that registers the plates slightly +//! off leaves a sliver of paper showing between them. A trap closes that gap: a thin extra shape +//! along the edge where the colours meet, lying over the darker colour, painted with the lighter +//! colour and set to overprint, so the lighter colour seems to spread into the darker one (a +//! lighter object on a darker background) or to choke it (a lighter background around a darker +//! object). The options: +//! +//! - **Thickness** (0.01–5000 pt): how far the lighter colour reaches into the darker one across +//! vertical edges. +//! - **Height/Width** (%): the reach across horizontal edges as a percentage of the vertical one +//! (100%: the same everywhere), for paper that stretches more one way on press. +//! - **Tint Reduction** (%): the trap paints the lighter colour at this percentage of its tint +//! (100: at full strength), so two light colours don't print a dark border where they overlap. +//! - **Traps with Process Color**: a trap of a swatch colour (a spot colour) is painted with the +//! equivalent CMYK process colour instead of the swatch. +//! - **Reverse Traps**: the darker colour spreads into the lighter one instead; not when the +//! darker colour is a rich black (100% K with other inks), which stays where it is. +//! - **Precision** and **Remove Redundant Points** (the effect only): the tolerance in points the +//! trap's outline is refitted to, and whether anchors that don't change its shape are dropped. +//! +//! Only objects filled with a solid colour take part; each object's visible part (what the objects +//! above leave of it) is what traps, so a trap follows the edges that actually show. Two objects +//! filled with the same colour need no trap. The lighter colour is the one with the higher CIE +//! lightness (L*); between colours of equal lightness, the one with less ink. +//! +//! Behaviour from the public descriptions only: +//! and +//! (feature names and +//! option ranges; the defaults of 0.25 pt, 100% and 40% are VectorCraft's own choices, the pages +//! give none). + +use serde_json::{Value, json}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId}; +use vectorcraft_geom::{FillRule, PathData, Rect}; +use vectorcraft_pathops as po; + +use crate::util::{flag, num}; + +/// Effect id of Effect › Pathfinder › Trap. +pub const TRAP: &str = "pathfinder.trap"; + +/// Undo label and name of the Pathfinder panel's Trap. +pub const TRAP_LABEL: &str = "Trap"; + +/// Parameter documentation of the effect (the panel command `object.pathfinder.trap` documents the +/// same keys but precision and removeRedundantPoints). +pub const EFFECT_PARAMS_DOC: &str = "{thickness?: pt 0.01..5000 (0.25; how far the lighter colour reaches into the darker one across vertical edges), heightWidth?: % 1..1000 (100; the reach across horizontal edges as a percentage of the vertical one), tintReduction?: % 0..100 (40; the trap paints the lighter colour at this percentage of its tint), trapsWithProcessColor?: bool (false; a swatch (spot) colour's trap is painted with its CMYK process equivalent), reverseTraps?: bool (false; the darker colour spreads into the lighter one, except a rich black), precision?: pt 0.001..10 (0.01; the tolerance the trap's outline is refitted to), removeRedundantPoints?: bool (true)} adds overprinting traps where the members' visible areas of different solid colours meet (groups and layers: live Pathfinder over the members)"; + +/// The options of a trap; [`TrapOptions::from_params`] reads and bounds them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TrapOptions { + /// Reach across vertical edges, in points (0.01..=5000). + pub thickness: f64, + /// Reach across horizontal edges as a fraction of `thickness` (0.01..=10). + pub height_width: f64, + /// The fraction of the lighter colour's tint the trap paints with (0..=1). + pub tint: f32, + /// Paint traps of swatch colours with their CMYK equivalent. + pub process: bool, + /// Spread the darker colour into the lighter one. + pub reverse: bool, + /// Refit tolerance of the trap outline, in points (0.001..=10). + pub precision: f64, + /// Drop anchors that don't change the trap's shape. + pub remove_redundant: bool, +} + +impl Default for TrapOptions { + fn default() -> Self { + TrapOptions { + thickness: 0.25, + height_width: 1.0, + tint: 0.4, + process: false, + reverse: false, + precision: po::DEFAULT_PRECISION, + remove_redundant: true, + } + } +} + +impl TrapOptions { + /// The options in `p` (keys of [`EFFECT_PARAMS_DOC`]): a missing or non-numeric value takes its + /// default and a value out of range its nearest bound, so a live effect always evaluates. + pub fn from_params(p: &Value) -> Self { + let d = TrapOptions::default(); + let (hw_min, hw_max) = po::TRAP_HEIGHT_WIDTH_RANGE; + TrapOptions { + thickness: num(p, "thickness", d.thickness).clamp(po::TRAP_MIN_WIDTH, po::TRAP_MAX_WIDTH), + height_width: (num(p, "heightWidth", d.height_width * 100.0) / 100.0).clamp(hw_min, hw_max), + tint: (num(p, "tintReduction", f64::from(d.tint) * 100.0) / 100.0).clamp(0.0, 1.0) as f32, + process: flag(p, "trapsWithProcessColor", d.process), + reverse: flag(p, "reverseTraps", d.reverse), + precision: num(p, "precision", d.precision).clamp(0.001, 10.0), + remove_redundant: flag(p, "removeRedundantPoints", d.remove_redundant), + } + } +} + +/// The Pathfinder panel Trap dialog's fields, at their defaults. +pub fn command_defaults() -> Value { + let d = TrapOptions::default(); + json!({ + "thickness": d.thickness, + "heightWidth": d.height_width * 100.0, + "tintReduction": (f64::from(d.tint) * 100.0).round(), + "trapsWithProcessColor": d.process, + "reverseTraps": d.reverse, + }) +} + +/// The Effect › Pathfinder › Trap dialog's fields, at their defaults. +pub fn effect_defaults() -> Value { + let d = TrapOptions::default(); + let mut v = command_defaults(); + if let Some(o) = v.as_object_mut() { + o.insert("precision".into(), json!(d.precision)); + o.insert("removeRedundantPoints".into(), json!(d.remove_redundant)); + } + v +} + +/// One object taking part in a trap, back to front: its outline and its fill. +#[derive(Clone, Debug)] +pub struct TrapSource { + pub path: PathData, + pub rule: FillRule, + pub fill: Paint, +} + +/// One trap: its outline, its paint (the spreading colour, tinted) and the sources it spreads +/// from and into (indices into the sources given to [`traps`]). +#[derive(Clone, Debug)] +pub struct TrapPiece { + pub path: PathData, + pub paint: Paint, + pub from: usize, + pub into: usize, +} + +/// The solid colour a source traps with: none for no fill, gradients, patterns and Registration. +fn trap_color(p: &Paint) -> Option { + if p.is_registration() { None } else { p.color() } +} + +fn finite(p: &PathData) -> bool { + p.subpaths.iter().flat_map(|sp| sp.anchors.iter()).all(|a| a.p.x.is_finite() && a.p.y.is_finite()) +} + +/// Is `a` lighter than `b`? Higher L*; at equal lightness, less ink. +fn lighter(a: Color, b: Color) -> bool { + let (la, lb) = (a.to_lab().l, b.to_lab().l); + if (la - lb).abs() > 1e-3 { + return la > lb; + } + let ink = |c: Color| c.to_cmyk().iter().sum::(); + ink(a) < ink(b) +} + +/// 100% black with other inks under it. +fn rich_black(c: Color) -> bool { + matches!(c, Color::Cmyk { c, m, y, k } if k >= 0.995 && c + m + y > 0.005) +} + +/// Do `a` and `b` print the same (no trap needed)? +fn same_color(a: &Paint, b: &Paint, ca: Color, cb: Color) -> bool { + let link = |p: &Paint| match p { + Paint::Solid { swatch, .. } => swatch.clone(), + _ => None, + }; + link(a) == link(b) && ca.to_cmyk().iter().zip(cb.to_cmyk()).all(|(x, y)| (x - y).abs() < 1e-4) +} + +/// The paint of a trap spreading `p` at tint `t`. +fn trap_paint(p: &Paint, color: Color, o: &TrapOptions) -> Paint { + // A solid colour shows its swatch at `tint` already, so tinting it again gives the swatch at + // `tint × t` ([`Color::tinted`] multiplies in every model). + let shown = color.tinted(o.tint); + match p { + Paint::Solid { swatch: Some(_), .. } if o.process => { + let [c, m, y, k] = shown.to_cmyk(); + Paint::solid(Color::cmyk(c, m, y, k)) + } + Paint::Solid { swatch: Some(s), tint, .. } => Paint::Solid { color: shown, swatch: Some(s.clone()), tint: tint * o.tint }, + _ => Paint::solid(shown), + } +} + +/// The visible part of each source: the faces of their arrangement that it is the top one of. +/// Sources that paint no fill hide nothing and have none. +fn visible(sources: &[TrapSource]) -> Vec { + let mut shapes = vec![]; + for (i, s) in sources.iter().enumerate() { + if !s.fill.is_none() && !s.path.is_empty() && finite(&s.path) { + shapes.push(po::Shape::new(s.path.clone(), s.rule, i as u64)); + } + } + let mut faces: Vec> = vec![vec![]; sources.len()]; + for r in po::regions(&shapes) { + if let Some(face) = r.sources.last().and_then(|&si| shapes.get(si)).and_then(|s| faces.get_mut(s.key as usize)) { + face.push(r.path); + } + } + faces + .into_iter() + .map(|mut f| match f.len() { + 0 => PathData::default(), + 1 => f.pop().unwrap_or_default(), + _ => po::unite_all(&f.iter().map(|p| (p, FillRule::NonZero)).collect::>()), + }) + .collect() +} + +/// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within +/// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their +/// left edge, so shapes spread over the page cost about one check each rather than one per pair. +fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> { + let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect(); + order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0))); + let mut pairs = vec![]; + for (k, &(i, a)) in order.iter().enumerate() { + // Later rects start at or right of `a`, so only how far right they start can rule them out. + for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) { + if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y { + pairs.push((i.min(j), i.max(j))); + } + } + } + pairs.sort_unstable(); + pairs +} + +/// The traps between `sources` (back to front) with options `o`: for each two sources of +/// different solid colours whose visible parts meet, the strip of the darker one's visible part +/// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per +/// [`trap_paint`]. Empty when no visible edges of different colours meet. +pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { + let colors: Vec> = sources.iter().map(|s| trap_color(&s.fill)).collect(); + if colors.iter().flatten().count() < 2 { + return vec![]; + } + let (w, r) = (o.thickness, o.height_width); + if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) { + return vec![]; // `trap_band` cuts nothing with these. + } + let vis = visible(sources); + // Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects + // the rest by the same bounds), so find those first instead of trying every pair. The hair + // keeps rounding from rejecting a pair `trap_band` would keep. + let rects: Vec> = + vis.iter().zip(&colors).map(|(p, c)| c.and(p.bounds()).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()))).collect(); + let (w, r) = (w.clamp(po::TRAP_MIN_WIDTH, po::TRAP_MAX_WIDTH), r.clamp(po::TRAP_HEIGHT_WIDTH_RANGE.0, po::TRAP_HEIGHT_WIDTH_RANGE.1)); + let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs())); + let hair = 1e-9 * (1.0 + extent + w * r.max(1.0)); + let mut out = vec![]; + for (i, j) in near_pairs(&rects, w + hair, w * r + hair) { + let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue }; + if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) { + continue; + } + let (mut from, mut into) = if lighter(ci, cj) { (i, j) } else { (j, i) }; + if o.reverse && colors[into].is_some_and(|c| !rich_black(c)) { + (from, into) = (into, from); + } + let mut band = po::trap_band(&vis[from], FillRule::NonZero, &vis[into], FillRule::NonZero, o.thickness, o.height_width, o.precision); + if o.remove_redundant { + band = po::remove_redundant_points(&band, o.precision); + } + if band.is_empty() || po::area(&band, FillRule::NonZero) <= 1e-9 { + continue; + } + let Some(color) = colors[from] else { continue }; + out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into }); + } + out +} + +/// A trap's appearance: one overprinting fill of `paint`, no stroke. +pub fn trap_appearance(paint: Paint) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(FillLayer { overprint: true, ..FillLayer::new(paint) })], ..Default::default() } +} + +/// A trap as a path object with id `id`. +pub fn trap_node(id: NodeId, piece: TrapPiece) -> Node { + Node::path(id, piece.path, trap_appearance(piece.paint)) +} + +#[cfg(test)] +mod tests { + use vectorcraft_geom::Rect; + use vectorcraft_geom::shapes::rectangle; + + use super::*; + + fn src(r: Rect, c: Color) -> TrapSource { + TrapSource { path: rectangle(r), rule: FillRule::NonZero, fill: Paint::solid(c) } + } + + fn yellow() -> Color { + Color::cmyk(0.0, 0.0, 1.0, 0.0) + } + + fn blue() -> Color { + Color::cmyk(1.0, 0.6, 0.0, 0.0) + } + + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.02 + } + + fn abutting() -> Vec { + vec![src(Rect::new(0.0, 0.0, 50.0, 40.0), yellow()), src(Rect::new(50.0, 0.0, 100.0, 40.0), blue())] + } + + #[test] + fn the_lighter_colour_spreads_into_the_darker_along_the_shared_edge() { + let o = TrapOptions { thickness: 3.0, tint: 1.0, ..Default::default() }; + let t = traps(&abutting(), &o); + assert_eq!(t.len(), 1); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 50.0) && near(b.x1, 53.0) && near(b.y0, 0.0) && near(b.y1, 40.0), "inside the blue, 3 pt wide: {b:?}"); + assert_eq!((t[0].from, t[0].into), (0, 1)); + assert_eq!(t[0].paint.color(), Some(yellow())); + let n = trap_node(NodeId(7), t[0].clone()); + assert!(n.appearance.fill().is_some_and(|f| f.overprint), "the trap overprints"); + assert!(n.appearance.stroke_paint().is_none()); + } + + #[test] + fn reverse_traps_spread_the_darker_colour() { + let o = TrapOptions { thickness: 2.0, reverse: true, tint: 1.0, ..Default::default() }; + let t = traps(&abutting(), &o); + assert_eq!(t.len(), 1); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 48.0) && near(b.x1, 50.0), "inside the yellow: {b:?}"); + assert_eq!(t[0].paint.color(), Some(blue())); + } + + #[test] + fn reverse_traps_leave_rich_black_alone() { + let rich = Color::cmyk(0.6, 0.4, 0.4, 1.0); + let s = vec![src(Rect::new(0.0, 0.0, 50.0, 40.0), yellow()), src(Rect::new(50.0, 0.0, 100.0, 40.0), rich)]; + let t = traps(&s, &TrapOptions { reverse: true, ..Default::default() }); + assert_eq!(t[0].from, 0, "the yellow still spreads into the rich black"); + } + + #[test] + fn tint_reduction_tints_the_trap_and_keeps_a_swatch_unless_process_colour() { + let mut s = abutting(); + s[0].fill = Paint::Solid { color: yellow(), swatch: Some("PANTONE Yellow".into()), tint: 1.0 }; + let t = traps(&s, &TrapOptions { tint: 0.5, ..Default::default() }); + match &t[0].paint { + Paint::Solid { color, swatch, tint } => { + assert_eq!(swatch.as_deref(), Some("PANTONE Yellow")); + assert!((tint - 0.5).abs() < 1e-6); + assert!((color.to_cmyk()[2] - 0.5).abs() < 1e-4, "{color:?}"); + } + p => panic!("{p:?}"), + } + let t = traps(&s, &TrapOptions { tint: 0.5, process: true, ..Default::default() }); + match &t[0].paint { + Paint::Solid { color, swatch: None, .. } => assert!(matches!(color, Color::Cmyk { .. })), + p => panic!("{p:?}"), + } + } + + #[test] + fn a_lighter_object_on_a_darker_background_spreads_all_round() { + // Yellow square on top of a blue background: a ring around the yellow, inside the blue. + let s = vec![src(Rect::new(0.0, 0.0, 100.0, 100.0), blue()), src(Rect::new(40.0, 40.0, 60.0, 60.0), yellow())]; + let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() }); + assert_eq!(t.len(), 1); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 39.0) && near(b.x1, 61.0) && near(b.y0, 39.0) && near(b.y1, 61.0), "{b:?}"); + assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0)); + } + + #[test] + fn no_trap_without_adjoining_edges_of_different_colours() { + let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())]; + assert!(traps(&apart, &TrapOptions::default()).is_empty()); + let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())]; + assert!(traps(&same, &TrapOptions::default()).is_empty()); + let mut unfilled = abutting(); + unfilled[1].fill = Paint::None; + assert!(traps(&unfilled, &TrapOptions::default()).is_empty()); + } + + #[test] + fn options_are_bounded_and_bad_numbers_take_defaults() { + let o = TrapOptions::from_params(&json!({"thickness": 1e300, "heightWidth": -5, "tintReduction": 400, "precision": "x"})); + assert_eq!(o.thickness, po::TRAP_MAX_WIDTH); + assert_eq!(o.height_width, po::TRAP_HEIGHT_WIDTH_RANGE.0); + assert_eq!(o.tint, 1.0); + assert_eq!(o.precision, po::DEFAULT_PRECISION); + let o = TrapOptions::from_params(&json!({"thickness": "NaN"})); + assert_eq!(o.thickness, TrapOptions::default().thickness); + // The widest trap still stays inside the darker shape. + let t = traps(&abutting(), &TrapOptions { thickness: f64::NAN, ..Default::default() }); + assert!(t.is_empty(), "a NaN thickness traps nothing"); + let t = traps(&abutting(), &TrapOptions { thickness: po::TRAP_MAX_WIDTH, ..Default::default() }); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 50.0) && near(b.x1, 100.0), "{b:?}"); + } + + #[test] + fn nan_coordinates_are_left_out() { + let mut s = abutting(); + s[1].path.subpaths[0].anchors[0].p.x = f64::NAN; + assert!(traps(&s, &TrapOptions::default()).is_empty()); + } + + /// 2,000 separated 10 pt squares in alternating colours on a 20 pt grid, then a yellow square + /// abutting the first (blue) one's right edge. + fn many_apart_and_one_abutting() -> Vec { + let mut s: Vec = (0..2000) + .map(|k| { + let (x, y) = (f64::from(k % 50) * 20.0, f64::from(k / 50) * 20.0); + src(Rect::new(x, y, x + 10.0, y + 10.0), if (k + k / 50) % 2 == 0 { blue() } else { yellow() }) + }) + .collect(); + s.push(src(Rect::new(10.0, 0.0, 15.0, 10.0), yellow())); + s + } + + #[test] + fn only_shapes_within_reach_are_paired() { + let s = many_apart_and_one_abutting(); + let rects: Vec> = s.iter().map(|t| t.path.bounds()).collect(); + assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired"); + // Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do. + let row: Vec> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect(); + assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]); + let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() }); + assert_eq!(t.len(), 1, "only the abutting pair traps"); + assert_eq!((t[0].from, t[0].into), (2000, 0)); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 9.0) && near(b.x1, 10.0) && near(b.y0, 0.0) && near(b.y1, 10.0), "{b:?}"); + } + + #[test] + fn pairing_by_bounds_keeps_every_trap_in_order() { + // Shapes abutting across x and y, overlapping, and half a point apart (within a 1 pt trap), + // none of them hiding another's edges: the traps are those of each pair alone. + let s = vec![ + src(Rect::new(0.0, 0.0, 100.0, 100.0), blue()), + src(Rect::new(40.0, 40.0, 60.0, 60.0), yellow()), + src(Rect::new(100.0, 0.0, 120.0, 50.0), yellow()), + src(Rect::new(0.0, 100.5, 50.0, 120.0), yellow()), + src(Rect::new(55.0, 100.0, 80.0, 130.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)), + ]; + let o = TrapOptions { thickness: 1.0, ..Default::default() }; + let got: Vec<(usize, usize)> = traps(&s, &o).iter().map(|t| (t.from, t.into)).collect(); + let mut want = vec![]; + for i in 0..s.len() { + for j in i + 1..s.len() { + if let Some(t) = traps(&[s[i].clone(), s[j].clone()], &o).first() { + want.push(if t.from == 0 { (i, j) } else { (j, i) }); + } + } + } + assert_eq!(want.len(), 4, "{want:?}"); + assert_eq!(got, want); + } +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 67eed5a4c..d27e70f51 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -22,7 +22,7 @@ use super::*; use crate::EngineError; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; -const CURRENT_SYMBOL: &str = "currentSymbol"; +pub(crate) const CURRENT_SYMBOL: &str = "currentSymbol"; /// Half the instance box `Node::geometric_bounds` uses for symbol instances. const HALF: f64 = vectorcraft_doc::SYMBOL_HALF; @@ -727,7 +727,7 @@ fn brush_freehand(s: &mut Session, p: &Value) -> Result { // ---------- symbols ---------- -fn natural_size(d: &Document, name: &str) -> (f64, f64) { +pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { d.unknown .get(SIZES_KEY) .and_then(|m| m.get(name)) @@ -735,7 +735,7 @@ fn natural_size(d: &Document, name: &str) -> (f64, f64) { .unwrap_or((2.0 * HALF, 2.0 * HALF)) } -fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { +pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); if let Some(o) = m.as_object_mut() { match size { @@ -746,7 +746,7 @@ fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { } /// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. -fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { +pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { let c = at - reg.offset_from_center(w, h); Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) } @@ -806,7 +806,7 @@ fn rename_natural_size(d: &mut Document, from: &str, to: &str) { } /// Normalise art for a symbol definition: returns (art, natural size, centre in the document). -fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { +pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; let (w, h) = (b.width().max(1.0), b.height().max(1.0)); let c = b.center(); diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 23a6b9dfa..d51ef6907 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -10,7 +10,7 @@ //! text besides it (see `flavours`). mod flavours; -mod resources; +pub(crate) mod resources; use std::collections::BTreeMap; use std::sync::Arc; diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 795dca48f..08098705f 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -421,8 +421,15 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?; let (sx, sy) = pt_per_px(img.ppi); let (w, h) = (img.width, img.height); - let image = - ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() }; + let image = ImageObject { + key: img.key.clone(), + width: w, + height: h, + xf: Affine::scale_non_uniform(sx, sy), + link: None, + placement: Default::default(), + pixels: None, + }; let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() }; clip.images.insert(img.key, img.blob); let count = s.load_clipboard(clip, p)?; diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 032ee02ff..ac01a8129 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -500,7 +500,7 @@ impl Imported { impl Clipboard { /// `nodes` (from `src`) and the resources of `src` they use. - pub(super) fn gather(src: &Document, nodes: Vec) -> Self { + pub(crate) fn gather(src: &Document, nodes: Vec) -> Self { let used = Used::of(&Defs::of(src), &nodes); let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::>(); let brushes = match used.names.get(&Res::Brush) { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 40c411803..b8a844f31 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { has_doc, flare ), - cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line), + cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2, fill?: bool (Fill Line: the current fill, else none)} → {id}", has_doc, line), cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral), cmd!( "shape.arc", @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Drawing Mode", [], Some("Shift+D"), - "{mode?: normal|behind|inside} (inside needs one selected path, compound path or type; no param cycles normal → behind → inside → normal, going from behind back to normal when Draw Inside can't draw into the selection)", + "{mode?: normal|behind|inside} (inside needs one selected path, compound path or type; no param cycles normal → behind → inside → normal, going from behind back to normal when Draw Inside can't draw into the selection; when the Draw Inside target is isolated, drawing or pasting into it moves isolation to its clip group)", has_doc, draw_mode ), @@ -149,16 +149,18 @@ fn insert_look_turned( let mode = s.draw_mode; let inside = s.draw_inside; let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied(); - let id = s.edit(label, |d, sel| { + let (id, into) = s.edit(label, |d, sel| { let id = d.alloc_id(); let mut n = Node::new(id, kind); look.apply(d, &mut n); n.name = name; n.bbox_angle = bbox_angle; + let mut into = None; match (mode, inside) { (crate::DrawMode::Inside, Some(target)) if d.node(target).is_some() => { let group = draw_inside_group(d, target)?; d.insert(Some(group), usize::MAX, n)?; + into = Some((target, group)); } (crate::DrawMode::Behind, _) => match behind_of.and_then(|b| d.position(b)) { Some((par, idx, _)) => { @@ -175,8 +177,11 @@ fn insert_look_turned( if select { sel.set([id]); } - Ok(id) + Ok((id, into)) })?; + if let Some((target, group)) = into { + isolate_draw_inside_group(s, target, group)?; + } Ok(json!({ "id": id.0 })) } @@ -204,6 +209,21 @@ pub(crate) fn draw_inside_group(d: &mut vectorcraft_doc::Document, target: NodeI Ok(gid) } +/// Draw Inside into the isolated object (#3705): isolation mode moves to the clip group the art +/// went into ([`draw_inside_group`]), so what is drawn or pasted inside the object is in isolation +/// mode with it, shown undimmed and picked by clicks, instead of locked beside it. Art added in +/// isolation mode belongs with what is isolated: it takes the isolated art's place when the mode +/// ends (). +/// A drag's next preview, replayed on the drag's snapshot, makes the group again under the same +/// id, so isolation stays on it; cancelling the drag restores the isolation it began with. +pub(crate) fn isolate_draw_inside_group(s: &mut Session, target: NodeId, group: NodeId) -> Result<()> { + let st = s.doc_mut()?; + if st.isolation == Some(target) { + st.isolation = Some(group); + } + Ok(()) +} + /// The object Draw Inside would draw into: the one selected object, when it is a path, a compound /// path or type. fn inside_target(st: &crate::DocState) -> Option { @@ -466,9 +486,10 @@ fn line(s: &mut Session, p: &Value) -> Result { let a = Point::new(f64_req(p, "x1", "shape.line")?, f64_req(p, "y1", "shape.line")?); let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?); let live = LiveShape::Line { a, b }; - // Lines are stroked, never filled (Illustrator ignores the fill for new open lines). + // Lines are stroked and, unless Fill Line asks for the current fill, not filled. + let fill = if switch_or(p, "fill", false, "shape.line")? { s.paint.fill.clone() } else { vectorcraft_color::Paint::None }; let look = s.new_art_look( - vectorcraft_color::Paint::None, + fill, if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() }, s.paint.stroke_width.max(0.1), ); diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 0a2a43e7b..c3407b6b9 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -101,10 +101,10 @@ type ImageRow = (String, u32, u32, Option); /// A Repeat object as Document Info lists it: its `repeatObjects` entry, and its row, which gives /// its name and then its kind with the number of instances it draws (for a grid, its rows and /// columns, followed by the number it draws when that is fewer). -fn repeat_entry(n: &Node, spec: &RepeatSpec) -> (Value, (String, String)) { +fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, String)) { let name = n.display_name(); let count = |n: usize, one: &str, many: &str| if n == 1 { format!("1 {one}") } else { format!("{n} {many}") }; - let instances = spec.instances(); + let instances = spec.instances(Some(d)); let mut entry = json!({ "id": n.id.0, "name": name, "instances": instances }); let kind = &spec.kind; let detail = match kind { @@ -377,7 +377,7 @@ fn info(s: &mut Session, p: &Value) -> Result { } } if let NodeKind::Repeat(spec) = &n.kind { - let (entry, row) = repeat_entry(n, spec); + let (entry, row) = repeat_entry(d, n, spec); repeats.push(entry); repeat_rows.push(row); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8757c99b1..1ac71598e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -411,6 +411,8 @@ fn undo(s: &mut Session, _: &Value) -> Result { } st.sync_new_art(); super::maskedit::follow_history(st, editing); + leave_isolation_if_gone(st); + super::globaledit::follow_history(st); Ok(json!({ "undone": label })) } @@ -436,9 +438,20 @@ fn redo(s: &mut Session, _: &Value) -> Result { } st.sync_new_art(); super::maskedit::follow_history(st, editing); + leave_isolation_if_gone(st); + super::globaledit::follow_history(st); Ok(json!({ "redone": label })) } +/// After undo or redo, isolation mode ends when the isolated object is no longer in the document, +/// such as the clip group Draw Inside made around the isolated object (#3705), rather than naming +/// an id a later object may take. +fn leave_isolation_if_gone(st: &mut crate::DocState) { + if st.isolation.is_some_and(|i| st.doc.node(i).is_none()) { + st.isolation = None; + } +} + /// History panel › Clear History: removes the steps after the current state and the steps before /// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a /// step, as Undo keeps it, so it is removed with the others and its change stays in the document. @@ -724,7 +737,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // In place, in front, in back: onto the active artboard when the UI names it. _ => vec![onto_artboard(&st.doc, p, clip)], }; - let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| { + let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| { let imported = clip.import_into(d, choices, same_doc); // Draw Inside: the clip group of the object drawn into, made on the first paste. let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?; @@ -771,8 +784,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are // right away (opening the file later would, but paste must not depend on a reopen). super::typecmd::refresh_bounds_of(d, &new_ids); - Ok((new_ids, imported, artboards)) + Ok((new_ids, imported, artboards, into)) })?; + if let (Some(target), Some(group)) = (inside, into) { + super::create::isolate_draw_inside_group(s, target, group)?; + } // The Artboard tool selects the pasted artboards, the first one active. if !artboards.is_empty() && s.tool_id() == "artboard" { s.set_tool_option("selected", &json!(artboards)); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 4a6eb0d04..dc2e18054 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -558,10 +558,12 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b let mut listed = vec![]; let m = expanded_node(d, n, brushes, &mut listed); out.extend(listed.into_iter().map(|(id, _)| id)); - if let Some(m) = m - && let Some(slot) = d.node_mut(id) - { - *slot = m; + if let Some(mut m) = m { + // Art made from type takes its emoji's pixels into the document. + d.adopt_pixels(&mut m); + if let Some(slot) = d.node_mut(id) { + *slot = m; + } } } @@ -626,7 +628,7 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br image => { let m = if container { // A group's or layer's own fills and strokes become art among its members. - effects::evaluate_container(&v).map(|mut m| { + effects::evaluate_container(Some(&*d), &v).map(|mut m| { members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art); m }) @@ -639,7 +641,7 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| { + effects::expand_outlined(Some(&*d), &v, symbol.as_deref()).map(|mut m| { expand_pieces(d, &mut m, true, brushes, &mut stroke_art); m }) @@ -1111,7 +1113,7 @@ mod tests { let glyphs = effects::outline_text(&node(&s, t)).unwrap(); let b = glyphs.geometric_bounds().unwrap(); assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}"); - let area = |n: &vectorcraft_doc::Node| effects::member_shapes(n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); + let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); let glyph_area = area(&glyphs); s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 512501d8d..efcccd241 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -5,12 +5,13 @@ //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip //! group shaped like the object. Create Gradient Mesh shares the sampling. +use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::spread_scale; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::live::GradientMesh; use vectorcraft_doc::pattern::{PatternDef, place_art}; @@ -412,6 +413,45 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { }) } +/// Expand the pattern fills under `root` that paint a transparent image into the tiles they paint +/// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other +/// pattern fills stay. A tile image counts as +/// transparent when its key is in `see_through` (it has see-through pixels), or when it, a group +/// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending +/// mode. Returns how many shapes changed. +pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet) -> Result { + let (_, n) = replace_leaves(d, root, |d, l| { + // Most leaves have no pattern fill: skip the ancestry walk for them. + if !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + return None; + } + let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); + let transparent = |d: &Document, i: &AppearanceItem| { + pattern_fill(d, i).is_some_and(|(f, def, _)| { + let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal; + def.art.iter().any(|a| transparent_image(a, see_through, on)) + }) + }; + expand_patterns_where(d, l, transparent) + })?; + Ok(n) +} + +/// Whether `n` has opacity or a blending mode of its own. +fn blended(n: &Node) -> bool { + n.opacity < 1.0 || n.blend != BlendMode::Normal +} + +/// Whether `n` is or holds a transparent image: one with see-through pixels (key in `keys`), or +/// with opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. +fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { + let on = on || blended(n); + match &n.kind { + NodeKind::Image(im) => on || keys.contains(&im.key), + _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))), + } +} + /// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it /// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects /// other than geometry effects, in appearance order: the other items stay on plain copies of the @@ -420,7 +460,15 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill /// lays a tile. fn expand_patterns(d: &mut Document, l: &Node) -> Option { - if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + expand_patterns_where(d, l, |_, _| true) +} + +/// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other +/// items. +fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool) -> Option { + if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i)) + { return None; } let reshaped = reshaped(l); @@ -431,7 +479,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option { let mut rest = vec![]; let mut expanded = false; for item in &l.appearance.items { - let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf)); + let fill = pattern_fill(d, item).filter(|_| which(d, item)).map(|(f, def, xf)| (f, def.clone(), xf)); let tiles = fill.and_then(|(f, def, xf)| { let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; let t = pattern_tiles(d, &def, xf, bounds); diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 8b507b986..ddb6905e9 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -243,6 +243,8 @@ impl Builder { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }) .collect(); @@ -1272,7 +1274,15 @@ impl Builder { let sy = img.height as f64 / f64::from(r.height.max(1)); let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))) + Some(NodeKind::Image(Box::new(ImageObject { + key: r.key, + width: r.width, + height: r.height, + xf, + link: None, + placement: Default::default(), + pixels: None, + }))) } } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 38d05b54c..fbc18b422 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -340,7 +340,17 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen /// `d` with one artboard, `rect`. pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document { - d.artboards = vec![Artboard { id: 1, name: name.into(), rect, locked: false, show_center_mark: false, show_cross_hairs: false, color: None }]; + d.artboards = vec![Artboard { + id: 1, + name: name.into(), + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }]; d } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 9fb7e06db..36b65d29e 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -15,8 +15,13 @@ //! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha //! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art //! under it, rendered from the document as it is, clipped to the area where it changes what shows; -//! the art under it stays as it was. With `omit` they are left out. An image that can't be decoded -//! is missing from the images that flattening and expanding raster effects draw, and the warnings +//! the art under it stays as it was. An image in a group with opacity or a blending mode is +//! flattened the same way, and a pattern fill that paints a transparent image (see-through +//! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is +//! expanded into its tiles first so its images flatten too. With `omit` the images with +//! see-through pixels, opacity or a blending mode of their own are left out, and pattern fills stay +//! as the writer draws them (their tiles opaque over white). An image that can't be decoded is +//! missing from the images that flattening and expanding raster effects draw, and the warnings //! then carry the raster exports' note once. //! //! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each @@ -117,6 +122,9 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result let (full, placed) = crate::cmd::place::document::full_documents(&one); let mut d = full.into_owned(); expand_appearances(&mut d); + if mode == SeeThrough::Flatten { + expand_image_patterns(&mut d)?; + } let images = see_through_images(&d, mode); if !images.is_empty() { note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); @@ -153,17 +161,30 @@ fn expand_appearances(d: &mut Document) { } } +/// Expand every layer's pattern fills that paint a transparent image into their tiles, so those +/// images flatten like any other transparent image. The writer would otherwise draw them opaque +/// over white. +fn expand_image_patterns(d: &mut Document) -> Result<()> { + let keys = super::super::flatten::see_through_images(d); + let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + for id in layers { + super::super::expand::expand_transparent_image_patterns(d, id, &keys)?; + } + Ok(()) +} + /// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as -/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only -/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over -/// white); all are left out with `omit`. +/// [`Document::walk`] counts). These are the images with see-through pixels, and the images +/// that have opacity or a blending mode of their own or, with `flatten`, of a group they are in. +/// With `flatten` only visible images are returned, because a hidden one (which the hidden objects +/// option may write) is written over white. With `omit` all of them are returned. fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { let keys = super::super::flatten::see_through_images(d); let mut out = vec![]; let mut at = 0; d.walk(|n| { if let NodeKind::Image(im) = &n.kind - && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal) + && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal || (mode == SeeThrough::Flatten && transparent(d, n.id))) { out.push((at, n.id)); } @@ -175,6 +196,12 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { out } +/// Whether node `id` or a group it is in has opacity or a blending mode. [`Document::ancestry`] +/// lists `id` itself last, so its own opacity and blending mode count too. +fn transparent(d: &Document, id: NodeId) -> bool { + d.ancestry(id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(|n| n.opacity < 1.0 || n.blend != BlendMode::Normal) +} + /// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows /// where it is, clipped to where it changes that; one that changes nothing is left out. Each is /// rendered from `d` as it was, so images over one another flatten as they look. @@ -188,7 +215,8 @@ fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> { let png = flat.png; let key = super::super::menucmds::unique_key(d, "flattened"); d.images.insert(key.clone(), ImageBlob::new("image/png", png)); - let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() }; + let image = + ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default(), pixels: None }; let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image); group.name = src.node(id).and_then(|n| n.name.clone()); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 72574e5e0..302240baa 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -454,6 +454,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Result { let layer = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::scale_non_uniform(sx, sy); - let mut n = - Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }))); + let mut n = Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })), + ); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index c819b9ae6..bea905946 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -979,6 +979,10 @@ fn may_write(path: &str) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_file(path: &str) -> Result> { may_read(path)?; + // A device or pipe may never end: only a regular file is read to its end. + if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() { + return Err(EngineError::Other(format!("{path}: not a file"))); + } std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -1184,7 +1188,7 @@ pub(crate) fn document_path_with( ) -> std::result::Result { use craft_docpath::Refused; let document = document.map(absolute_path); - let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new)); + let resolve = |document: Option<&str>| craft_docpath::resolve_absolute(stored, document.map(std::path::Path::new)); let path = match resolve(document.as_deref()) { Err(Refused::Network) => { let share = document.as_deref().and_then(share).ok_or(Refused::Network)?; @@ -1192,9 +1196,6 @@ pub(crate) fn document_path_with( } path => path?, }; - if !path.is_absolute() { - return Err(Refused::Invalid); - } path.into_os_string().into_string().map_err(|_| Refused::Invalid) } @@ -1366,15 +1367,17 @@ pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { let mut taken = std::collections::HashSet::new(); + // The next number to try for each base: a name repeated many times doesn't search the numbers + // taken before it. + let mut next: std::collections::HashMap = std::collections::HashMap::new(); names .into_iter() .map(|base| { let mut name = base.clone(); - for i in 2.. { - if taken.insert(name.to_lowercase()) { - break; - } - name = format!("{base}-{i}"); + while !taken.insert(name.to_lowercase()) { + let n = next.entry(base.to_lowercase()).or_insert(1); + *n += 1; + name = format!("{base}-{n}"); } name }) diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 166eda8b9..170b55f88 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -385,22 +385,27 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result Document { let mut d = doc.clone(); let Some(a) = doc.artboards.get(i) else { return d }; - let r = a.rect; d.artboards = vec![a.clone()]; - // It opens fitted to its artboard, not at the master file's view. + keep_art_on(&mut d, a.rect); + d +} + +/// Reduce `d` to the objects touching `r`, in their layers (resources are kept), opening +/// fitted to its artboard rather than at the master file's view. +pub(super) fn keep_art_on(d: &mut Document, r: vectorcraft_geom::Rect) { d.last_view = None; d.layers_open = None; for layer in &mut d.layers { keep_on(layer, r); } - d } -/// Keep in layer `n` (and its sublayers) only the objects touching `r`. +/// Keep in layer `n` (and its sublayers) only the objects touching `r`, and those without bounds. fn keep_on(n: &mut Arc, r: vectorcraft_geom::Rect) { let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0; if let Some(children) = Arc::make_mut(n).children_mut() { - children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches)); + // An object whose bounds can't be measured (type in a missing font) stays: dropping it loses art. + children.retain(|c| c.is_layer() || c.visual_bounds().is_none_or(touches)); for c in children.iter_mut().filter(|c| c.is_layer()) { keep_on(c, r); } diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 9c33d7e24..1d9c0303c 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -784,8 +784,21 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> write(all, "pdf", &options)? } else if r.whole_document() && item.artboard.is_some() { let b = item.artboard.unwrap_or(0); - let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone(); - write(&d, r.format.id, &r.options_for(item.region, None)?)? + // That artboard alone, as Save's separateArtboards writes it: its art, not the others' (#3868). + let d = board_docs + .entry(b) + .or_insert_with(|| { + let mut d = single_artboard(&item.doc, item.region, &item.name); + super::save::keep_art_on(&mut d, item.region); + Arc::new(d) + }) + .clone(); + let mut options = r.options_for(item.region, None)?; + // An EPS page is the artboard, not its art's bounds: an artboard without art still writes. + if r.format.id == "eps" { + options["useArtboards"] = json!(true); + } + write(&d, r.format.id, &options)? } else { write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)? }; diff --git a/crates/engine/src/cmd/fileio/tests_batch.rs b/crates/engine/src/cmd/fileio/tests_batch.rs index 5535d8ed6..b889eb2d4 100644 --- a/crates/engine/src/cmd/fileio/tests_batch.rs +++ b/crates/engine/src/cmd/fileio/tests_batch.rs @@ -134,17 +134,25 @@ fn layers_and_the_selection_are_exported_by_name() { assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected"); } -/// Document formats write each artboard as a document of its own: one artboard, its art. +/// Document formats write each artboard as a document of its own: one artboard, its art and +/// not the other artboards' (#3868). #[test] fn document_formats_write_one_artboard_each() { let mut s = session(2); + let second = s.doc().unwrap().doc.artboards[1].rect; + s.execute("shape.ellipse", &json!({"x": second.x0 + 5.0, "y": 5, "width": 10, "height": 10})).unwrap(); let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]})); let n = names(&r); assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]); - let native = &files(&r)[0].1; - let mut t = Session::new(); - t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap(); - assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone"); + let all = files(&r); + for (name, bytes) in &all { + let mut t = Session::new(); + t.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + let d = &t.doc().unwrap().doc; + assert_eq!(d.artboards.len(), 1, "{name}: that artboard alone"); + let objects: usize = d.layers.iter().filter_map(|l| l.children()).map(|c| c.len()).sum(); + assert_eq!(objects, 1, "{name}: only its own artboard's art"); + } } #[test] diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 496b16f81..63e93fe57 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -248,6 +248,7 @@ fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() { xf: vectorcraft_geom::Affine::scale(20.0), link: None, placement: Default::default(), + pixels: None, }; let (id, layer) = (broken.alloc_id(), broken.layers[0].id); broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 05cafeb6a..61a96bd5e 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -162,6 +162,48 @@ fn saving_several_artboards_as_plain_svg_warns_they_are_lost() { assert_eq!(artboards(&session(1).execute("document.save", &json!({"format": "svg"})).unwrap()), 0); } +/// #3867: a Preserve Editing SVG opened from disk and saved with plain Save keeps the full +/// document (every artboard), not just the artwork of the first. +#[test] +fn reopened_preserve_editing_svg_saves_as_the_full_document() { + let dir = tmp_dir("reopen"); + let path = dir.join("keep.svg").to_string_lossy().to_string(); + session(3).execute("document.save", &json!({"path": path, "svg": {"preserveEditing": true}})).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 3); + s.execute("document.save", &json!({})).unwrap(); + let mut t = Session::new(); + let r = t.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(r["restored"], json!(true), "{r}"); + assert_eq!(t.doc().unwrap().doc.artboards.len(), 3, "every artboard survives open and Save"); + let _ = std::fs::remove_dir_all(dir); +} + +/// #3869: small capitals, superscript and subscript, and tab leaders, which SVG text can't draw, +/// are written as outlines (as vertical type and ruby are) rather than as plain text at full size; +/// type without them stays text. +#[test] +fn type_svg_text_cant_draw_is_written_as_outlines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 200})).unwrap(); + s.execute("text.create", &json!({"x": 50, "y": 100, "text": "Small caps x2 H2O", "size": 30})).unwrap(); + let plain = svg(&mut s, json!({})); + assert!(plain.contains(" Node { d.images.insert(key.clone(), r.blob); let image = Node::new( d.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key, + width: r.width, + height: r.height, + xf: r.xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); let Some(path) = r.clip else { return image }; super::rasterfx::clip_group(d, path, FillRule::NonZero, image) @@ -1226,13 +1234,14 @@ impl Scratch { NodeKind::Text(_) => outlined_text(n), NodeKind::SymbolInstance { symbol, .. } => { let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n)); - art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g)) + art.and_then(|a| effects::outline_art(Some(&self.doc), n, Some(&a))).map(|g| carry(n, g)) } // A placed document stays one: its art's resources aren't the document's. NodeKind::PlacedDocument(_) => None, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { let hook: &dyn Fn(&Node) -> Option = &effects::outline_text; - Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook)))) + let hooks = vectorcraft_doc::live::Hooks { outline: Some(hook), symbols: Some(&self.doc), ..Default::default() }; + Some(carry(n, vectorcraft_doc::live::expanded_group_hooks(n, hooks))) } NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => outline_strokes(&mut self.doc, &self.brushes, n), _ => None, diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs new file mode 100644 index 000000000..c015ffaa2 --- /dev/null +++ b/crates/engine/src/cmd/globaledit.rs @@ -0,0 +1,600 @@ +//! Select › Start Global Edit / Stop Global Edit, and the Global Edit Options. +//! +//! Behaviour from the public help pages "Edit similar objects" +//! () +//! and "Update assets quickly with Global Edit" +//! (), in our words: +//! +//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the +//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance +//! (the same fill and stroke too) and Size (the same width and height too). The search covers +//! every artboard and the canvas around them by default; it can be limited to a range of +//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type, +//! masks, linked files and selections of more than one object are not supported. +//! - While Global Edit is on, an edit made to one of the found objects is made to all of them, +//! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt +//! match to 10 pt. Stop Global Edit (or Escape) ends it. +//! +//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the +//! object that was selected, so that the Transform panel and the bounding box measure that object +//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps +//! the first object's outline onto its own. After every edit ([`propagate`], run inside +//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit +//! changed is the model: what changed about it is given to every other member, and nothing else, +//! so members found by shape alone keep their own fills and strokes where the edit didn't touch +//! them: +//! +//! - its outline (and anchors), placed through that transform, so in proportion to each member's +//! size. Only geometry is placed so: no stroke weight is scaled to a member's size; +//! - its opacity, blending, knockout and the object's effects, each when it changed; +//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending, +//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the +//! same rank (the second fill from the bottom to the second fill), and a member without one is +//! left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure a +//! stroke was drawn with stays each member's own; +//! - a fill or stroke added or removed: a member whose fills and strokes lie in the model's +//! former order gains the same item at the same place (or loses the one at that place). A +//! member with another order, and any other change to the list (items moved, or several added +//! or removed at once), takes the model's whole list of fills and strokes. +//! +//! Members that are hidden or locked at the time are left as they are. An edit that would take any +//! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that +//! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag +//! or scrub, an edit that failed), the members it lacks (or no longer supports) leave the Global +//! Edit, and it ends when fewer than two are left ([`follow_history`]), so that an object made +//! later under a freed id is never edited. Rotated copies, groups (the help page "Edit groups with +//! similar objects") and excluding a member with a Shift-click are not covered. + +use serde::Serialize; +use serde_json::{Value, json}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape}; + +use super::wand::{fill_of, stroke_of}; +use super::*; + +const C: &str = "select.globalEdit"; +const OPTIONS: &str = "select.globalEditOptions"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "select.globalEdit", + "Start Global Edit", + ["Select"], + None, + "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object, with the objects of the same shape (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard) → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}", + can_toggle, + toggle + ), + cmd!( + "select.globalEditOptions", + "Global Edit Options", + [], + None, + "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} set the options Start Global Edit matches with (defaults: shape only, all artboards, objects on the canvas included) → {appearance, size, artboards, includeCanvas, count: the objects Start Global Edit would take now (0 unless one supported object is selected)}", + has_doc, + set_options + ), + ] +} + +/// The Global Edit Options: what a similar object must share with the selected one besides its +/// shape, and where it may lie. Kept for the session. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Options { + /// Match the fill and stroke too. + pub appearance: bool, + /// Match the width and height too. + pub size: bool, + /// The artboards to search (1-based range as typed); `None`: all of them. + pub artboards: Option, + /// Include the objects that lie on no artboard. + pub include_canvas: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { appearance: false, size: false, artboards: None, include_canvas: true } + } +} + +impl Options { + /// These options with the ones `p` gives, checked against `d`'s artboards. + fn with(&self, p: &Value, d: &Document, cmd: &str) -> Result { + let mut o = self.clone(); + o.appearance = bool_or(p, "appearance", o.appearance); + o.size = bool_or(p, "size", o.size); + o.include_canvas = bool_or(p, "includeCanvas", o.include_canvas); + match p.get("artboards") { + None | Some(Value::Null) => {} + Some(Value::String(s)) if s.trim().is_empty() || s.trim().eq_ignore_ascii_case("all") => o.artboards = None, + Some(Value::String(s)) => { + vectorcraft_doc::range::parse_range(s, d.artboards.len()).map_err(|e| bad(cmd, e))?; + o.artboards = Some(s.trim().to_string()); + } + Some(v) => return Err(bad(cmd, format!("`artboards`: all or a range such as 1, 3-5, not {v}"))), + } + Ok(o) + } + + fn json(&self, count: usize) -> Value { + json!({ + "appearance": self.appearance, + "size": self.size, + "artboards": self.artboards.as_deref().unwrap_or("all"), + "includeCanvas": self.include_canvas, + "count": count, + }) + } +} + +/// Outlines with their layer colours ([`highlights`]). +pub type Outlines = Vec<(BezPath, [u8; 3])>; + +/// A Global Edit in progress (per document; not saved, not an undo step). +#[derive(Clone, Debug, PartialEq)] +pub struct GlobalEdit { + /// The objects edited together, the one selected at the start first, each with the transform + /// (a move and a uniform scale) that maps that first object's outline onto its own. + pub members: Vec<(NodeId, Affine)>, + /// [`highlights`] as of a document revision: the canvas asks every frame. + outlines: std::cell::RefCell>, +} + +/// An outline reduced to its form: the anchors and handles placed in a unit box, with what tells +/// such outlines apart (kind, fill rule, subpaths and their anchor counts, open or closed). +struct Form { + tag: Vec, + symbol: Option, + points: Vec, + /// The unit box → the object. + frame: Affine, +} + +impl Form { + fn of(n: &Node) -> Option { + let mut tag = vec![]; + let mut points = vec![]; + let mut symbol = None; + let mut push = |path: &PathData, tag: &mut Vec| { + tag.push(path.subpaths.len() as u64); + for sp in &path.subpaths { + tag.extend([u64::from(sp.closed), sp.anchors.len() as u64]); + points.extend(sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out])); + } + }; + let rule = |r: vectorcraft_geom::FillRule| u64::from(r == vectorcraft_geom::FillRule::EvenOdd); + match &n.kind { + NodeKind::Path { path, rule: r, clipping: false, guide: false, .. } => { + tag.extend([0, rule(*r)]); + push(path, &mut tag); + } + NodeKind::Compound { children, rule: r } => { + tag.extend([1, rule(*r), children.len() as u64]); + for c in children { + push(c.path_data()?, &mut tag); + } + } + NodeKind::SymbolInstance { symbol: s, xf } => { + tag.push(2); + symbol = Some(s.clone()); + points.extend([*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)]); + } + _ => return None, + } + let first = *points.first()?; + let b = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)); + let side = b.width().max(b.height()); + if !(side.is_finite() && side > 1e-9) { + return None; + } + let frame = Affine::translate(b.origin().to_vec2()) * Affine::scale(side); + let back = frame.inverse(); + points.iter_mut().for_each(|p| *p = back * *p); + Some(Self { tag, symbol, points, frame }) + } + + fn same(&self, o: &Form) -> bool { + self.tag == o.tag + && self.symbol == o.symbol + && self.points.len() == o.points.len() + && self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6) + } +} + +/// Global Edit supports paths, compound paths and symbol instances; not images, type, linked +/// files, objects with an opacity mask, clipping paths or guides (the help page lists images, +/// text, masks and links as unsupported). +fn supported(n: &Node) -> bool { + n.extras().mask.is_none() && Form::of(n).is_some() +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0) +} + +/// The members a Global Edit started on the one selected object would take, with `o`: that +/// object first, then the others in paint order. +fn matches(s: &Session, o: &Options) -> Result> { + let st = s.doc()?; + let d = &st.doc; + let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) }; + let key = d.node(*id).ok_or_else(|| bad(C, "nothing selected"))?; + let form = match Form::of(key) { + Some(f) if supported(key) => f, + _ => return Err(bad(C, "Global Edit works on paths, compound paths and symbol instances")), + }; + let symbols = d.symbol_boxes(); + let size = |n: &Node| n.geometric_bounds_with(&symbols).map(|b| (b.width(), b.height())); + let key_size = size(key); + let (fill, stroke) = (fill_of(key), stroke_of(key)); + let boards: Option> = match &o.artboards { + None => None, + Some(r) => Some( + vectorcraft_doc::range::parse_range(r, d.artboards.len()) + .map_err(|e| bad(C, e))? + .into_iter() + .filter_map(|i| d.artboards.get(i).map(|a| a.rect)) + .collect(), + ), + }; + let on = |r: Rect, b: Rect| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center()); + let in_scope = |n: &Node| { + let Some(b) = n.geometric_bounds_with(&symbols) else { return false }; + if !d.artboards.iter().any(|a| on(a.rect, b)) { + return o.include_canvas; + } + boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b))) + }; + let mut out = vec![(*id, Affine::IDENTITY)]; + let back = form.frame.inverse(); + super::select::walk_selectable(d, st.isolation, |n| { + if n.id == *id { + return false; + } + match &n.kind { + // Look into layers and plain groups (clip groups too: their art, not their clipping path). + NodeKind::Layer { .. } => return true, + NodeKind::Group { .. } => { + let x = n.extras(); + return x.graph.is_none() && x.shaper.is_none() && x.dimension.is_none() && x.objects_on_path.is_none() && x.trace.is_none(); + } + _ => {} + } + if let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n)) + && (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2))) + && (!o.appearance || { + let (f2, s2) = (fill_of(n), stroke_of(n)); + super::select::same_paint(&fill, &f2, true) && super::select::same_paint(&stroke.0, &s2.0, true) && close(stroke.1, s2.1) + }) + && in_scope(n) + { + out.push((n.id, f.frame * back)); + } + // Compound paths, symbol instances and the other objects are taken whole. + false + }); + Ok(out) +} + +fn can_toggle(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.global_edit.is_some() { + return Ok(()); + } + let [id] = st.selection.objects.as_slice() else { + return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); + }; + match st.doc.node(*id) { + Some(n) if supported(n) => Ok(()), + _ => Err("Global Edit works on paths, compound paths and symbol instances".into()), + } +} + +fn toggle(s: &mut Session, p: &Value) -> Result { + if s.doc()?.global_edit.is_some() { + let st = s.doc_mut()?; + st.global_edit = None; + st.revision += 1; + return Ok(json!({ "active": false })); + } + let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?; + let members = matches(s, &o)?; + if members.len() < 2 { + return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again")); + } + let ids: Vec = members.iter().map(|(id, _)| id.0).collect(); + let st = s.doc_mut()?; + st.global_edit = Some(GlobalEdit { members, outlines: Default::default() }); + st.revision += 1; + Ok(json!({ "active": true, "count": ids.len(), "ids": ids })) +} + +fn set_options(s: &mut Session, p: &Value) -> Result { + let o = s.global_edit_options.with(p, &s.doc()?.doc, OPTIONS)?; + s.global_edit_options = o.clone(); + Ok(o.json(match_count(s))) +} + +/// How many objects Start Global Edit would take now with the session's options (the Result(s) +/// line of the options): 0 unless one supported object is selected. +pub fn match_count(s: &Session) -> usize { + match s.active() { + Some(st) if st.global_edit.is_none() => matches(s, &s.global_edit_options).map_or(0, |m| m.len()), + Some(st) => st.global_edit.as_ref().map_or(0, |g| g.members.len()), + None => 0, + } +} + +/// Is `id` there, shown and unlocked, with every layer and group it is in? +fn editable(d: &Document, id: NodeId) -> bool { + d.ancestry(id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) +} + +/// After the document of `st` was put back to an earlier one (Undo, Redo, a cancelled interaction +/// or undo group, a failed or panicked command, a preview starting again, Discard Empty Type): the +/// members it lacks (or no longer supports) leave the Global Edit, so that an object made later +/// under a freed id is never edited; with fewer than two left, Global Edit ends. +pub(crate) fn follow_history(st: &mut crate::DocState) { + let Some(ge) = &mut st.global_edit else { return }; + let d = &st.doc; + ge.members.retain(|(id, _)| d.node(*id).is_some_and(supported)); + if ge.members.len() < 2 { + st.global_edit = None; + } +} + +/// What an edit changed about a member: what [`propagate`] gives the others. +#[derive(Default)] +struct Changed { + outline: bool, + /// The member's appearance before and after the edit, when it changed. + appearance: Option<[Shared; 2]>, + opacity: bool, + blend: bool, + isolate: bool, + knockout: bool, + knockout_shape: bool, +} + +impl Changed { + fn between(a: &Node, b: &Node) -> Self { + Self { + outline: a.kind != b.kind || a.bbox_angle != b.bbox_angle, + appearance: (a.appearance != b.appearance).then(|| [a.appearance.clone(), b.appearance.clone()]), + opacity: a.opacity != b.opacity, + blend: a.blend != b.blend, + isolate: a.isolate != b.isolate, + knockout: a.knockout != b.knockout, + knockout_shape: a.knockout_shape != b.knockout_shape, + } + } + + fn any(&self) -> bool { + self.outline || self.appearance.is_some() || self.opacity || self.blend || self.isolate || self.knockout || self.knockout_shape + } + + /// Give `n` what changed, from `model` (the changed member placed on `n`). + fn apply(&self, model: Node, n: &mut Node) { + if self.outline { + n.kind = model.kind; + n.bbox_angle = model.bbox_angle; + } + if let Some([a, b]) = &self.appearance { + give_appearance(a, b, &model.appearance, &mut n.appearance); + } + if self.opacity { + n.opacity = model.opacity; + } + if self.blend { + n.blend = model.blend; + } + if self.isolate { + n.isolate = model.isolate; + } + if self.knockout { + n.knockout = model.knockout; + } + if self.knockout_shape { + n.knockout_shape = model.knockout_shape; + } + } +} + +/// Set each field of `$n` that differs between `$a` and `$b` to the binding of the same name. +macro_rules! give_fields { + ($a:ident, $b:ident, $n:ident: $($f:ident),+) => { + $( + if $a.$f != $b.$f { + $n.$f = Clone::clone($f); + } + )+ + }; +} + +/// Give `n` what changed about the model's appearance from `a` (before the edit) to `b` (after), +/// with the values of `m` (`b` placed on `n`'s object); the module doc gives the rules. +fn give_appearance(a: &Appearance, b: &Appearance, m: &Appearance, n: &mut Shared) { + let Appearance { items, effects, contents_index } = m; + if a.effects != b.effects || a.contents_index != b.contents_index { + let n = &mut **n; + give_fields!(a, b, n: effects, contents_index); + } + if a.items == b.items { + return; + } + let kinds = |v: &[AppearanceItem]| v.iter().map(AppearanceItem::is_fill).collect::>(); + if kinds(&a.items) == kinds(&b.items) { + // Fills and strokes kept in order: each changed one goes to the member's of its kind and + // rank. + for (k, ((p, q), mq)) in a.items.iter().zip(&b.items).zip(items).enumerate() { + if p == q { + continue; + } + let rank = a.items.iter().take(k).filter(|x| x.is_fill() == p.is_fill()).count(); + if let Some(t) = n.items.iter_mut().filter(|x| x.is_fill() == p.is_fill()).nth(rank) { + give_item(p, q, mq, t); + } + } + return; + } + // One item added (`grew`) or removed at `at`, the others kept in order; the member must hold + // the model's former kinds in order. + let one = |long: &[AppearanceItem], short: &[AppearanceItem]| { + if long.len() != short.len() + 1 { + return None; + } + let at = long.iter().zip(short).position(|(x, y)| x != y).unwrap_or(short.len()); + let mut rest = kinds(long); + rest.remove(at); + (rest == kinds(short)).then_some(at) + }; + let step = match (one(&b.items, &a.items), one(&a.items, &b.items)) { + (Some(at), _) => Some((at, true)), + (None, Some(at)) => Some((at, false)), + _ => None, + }; + match step { + Some((at, grew)) if kinds(&n.items) == kinds(&a.items) => { + let n = &mut n.items; + for (i, (p, t)) in a.items.iter().zip(n.iter_mut()).enumerate() { + // The model's item after the edit that was `p` before it. + let j = match (grew, i < at) { + (_, true) => i, + (true, false) => i + 1, + (false, false) if i == at => continue, + (false, false) => i - 1, + }; + if let (Some(q), Some(mq)) = (b.items.get(j), items.get(j)) + && p != q + { + give_item(p, q, mq, t); + } + } + match (grew, items.get(at)) { + (true, Some(new)) => n.insert(at.min(n.len()), new.clone()), + (false, _) if at < n.len() => { + n.remove(at); + } + _ => {} + } + } + _ => n.items = items.clone(), + } +} + +/// Give the member's fill or stroke `n` the settings of the model's that changed from `a` to `b`, +/// with the values of `m` (`b` placed on the member). A stroke's pen pressure stays its own. +fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut AppearanceItem) { + use AppearanceItem::{Fill, Stroke}; + match (a, b, m, n) { + (Fill(a), Fill(b), Fill(m), Fill(n)) => { + let FillLayer { paint, opacity, blend, visible, effects, overprint } = m; + give_fields!(a, b, n: paint, opacity, blend, visible, effects, overprint); + } + (Stroke(a), Stroke(b), Stroke(m), Stroke(n)) => { + let StrokeLayer { + paint, + width, + cap, + join, + miter_limit, + align, + dash, + start_arrow, + end_arrow, + arrow_scale, + profile, + brush, + opacity, + blend, + visible, + effects, + arrow_align, + overprint, + gradient_mode, + pressure: _, + } = m; + give_fields!(a, b, n: paint, width, cap, join, miter_limit, align, dash, start_arrow, end_arrow, arrow_scale, profile, brush, opacity, blend, visible, effects, arrow_align, overprint, gradient_mode); + } + _ => {} + } +} + +/// Give the children of a copy placed on another object ids of that object's: its own children's +/// in order, new ids for any more and for everything deeper. +fn renumber(children: &mut [std::sync::Arc], own: &[NodeId], d: &mut Document) { + for (i, c) in children.iter_mut().enumerate() { + let c = std::sync::Arc::make_mut(c); + c.id = own.get(i).copied().unwrap_or_else(|| d.alloc_id()); + if let Some(ch) = c.children_mut() { + renumber(ch, &[], d); + } + } +} + +/// After an edit made while Global Edit is on (`before` → `d`): the first member selected before +/// or after the edit that it changed is the model, and every other member that is shown and +/// unlocked gets what changed about it, placed by the members' transforms. → the members it +/// changed so (which [`Session::edit`] checks stay within the canvas, as it checks the selection). +pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, selected: [&[NodeId]; 2]) -> Vec { + let model = ge.members.iter().filter(|(id, _)| selected.iter().any(|s| s.contains(id))).find_map(|(id, xf)| { + let (a, b) = (before.node(*id)?, d.node(*id)?); + if std::ptr::eq(a, b) { + return None; + } + let changed = Changed::between(a, b); + changed.any().then(|| (*id, *xf, changed, b.clone())) + }); + let Some((model_id, model_xf, changed, model)) = model else { return vec![] }; + let back = model_xf.inverse(); + let mut edited = vec![]; + for (id, xf) in &ge.members { + if *id == model_id || !editable(d, *id) { + continue; + } + let mut placed = model.clone(); + placed.transform(*xf * back, Scaling::default()); + if changed.outline { + let own: Vec = d.node(*id).and_then(Node::children).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); + if let Some(ch) = placed.children_mut() { + renumber(ch, &own, d); + } + } + if let Some(n) = d.node_mut(*id) { + changed.apply(placed, n); + edited.push(*id); + } + } + edited +} + +/// The outlines of the members of the active document's Global Edit that are not selected, in +/// their layer's colour: the canvas highlights them while Global Edit is on. +pub fn highlights(s: &Session) -> Outlines { + let Some(st) = s.active() else { return vec![] }; + let Some(ge) = &st.global_edit else { return vec![] }; + if let Some((rev, out)) = &*ge.outlines.borrow() + && *rev == st.revision + { + return out.clone(); + } + let symbols = st.doc.symbol_boxes(); + let out: Vec<_> = ge + .members + .iter() + .filter(|(id, _)| !st.selection.contains(*id)) + .filter_map(|(id, _)| { + let n = st.doc.node(*id)?; + let path = match n.stroke_path() { + Some(p) => p, + None => n.geometric_bounds_with(&symbols)?.to_path(0.1), + }; + Some((path, st.doc.layer_color(*id))) + }) + .collect(); + *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); + out +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index c6c941a31..5c31ca009 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -125,7 +125,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool (as artboard.setProps takes them; default false)} (default place: right of the last)", + "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)", has_doc, artboard_new ), @@ -143,7 +143,7 @@ pub fn specs() -> Vec { "Artboard Options…", ["Window", "Artboards"], None, - "{index | artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center; both are screen-only, as the artboard's edges are: exports and print leave them out), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}", + "{index | artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center), showVideoSafeAreas?: bool (Show Video Safe Areas: the canvas draws the action-safe area 3.5% and the title-safe area 5% in from each edge, as EBU R 95 sets them; the three are screen-only, as the artboard's edges are: exports and print leave them out), videoRulerPixelAspectRatio?: number 0.1–10 (Video Ruler Pixel Aspect Ratio: the width of one video pixel over its height, which View › Rulers › Show Video Rulers counts the artboard in; default 1, square pixels), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}", has_doc, artboard_set ), @@ -813,19 +813,44 @@ fn artboard_color(p: &Value, cmd: &str) -> Result Result> { + match p.get("videoRulerPixelAspectRatio") { + None => Ok(None), + Some(v) => v + .as_f64() + .filter(|r| vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE.contains(r)) + .map(Some) + .ok_or_else(|| bad(cmd, "videoRulerPixelAspectRatio must be a number from 0.1 to 10")), + } +} + fn artboard_new(s: &mut Session, p: &Value) -> Result { let d0 = &s.doc()?.doc; let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1); let r = rect_from(p, def); let color = artboard_color(p, "artboard.new")?.flatten(); - check_switches(p, &["showCenterMark", "showCrossHairs"], "artboard.new")?; + check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); + let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); + let video_ruler_pixel_aspect_ratio = video_pixel_aspect(p, "artboard.new")?.unwrap_or(1.0); let idx = s.edit("New Artboard", |d, _| { let id = d.next_artboard_id(); let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string); - d.artboards.push(Artboard { id, name, rect: r, locked: false, show_center_mark, show_cross_hairs, color }); + d.artboards.push(Artboard { + id, + name, + rect: r, + locked: false, + show_center_mark, + show_cross_hairs, + show_video_safe_areas, + video_ruler_pixel_aspect_ratio, + color, + }); Ok(d.artboards.len() - 1) })?; Ok(json!({ "index": idx })) @@ -848,9 +873,10 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result { ok() } -/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock and -/// display marks (Show Center Mark, Show Cross Hairs), how far it moved, whether it was resized, -/// the map its art and guides take when they go with it (`fit` when it scales them), and that art. +/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock, +/// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel +/// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when +/// they go with it (`fit` when it scales them), and that art. struct ArtboardEdit<'a> { index: usize, now: Rect, @@ -859,6 +885,8 @@ struct ArtboardEdit<'a> { lock: Option, center_mark: Option, cross_hairs: Option, + safe_areas: Option, + pixel_aspect: Option, delta: vectorcraft_geom::Vec2, resized: bool, fit: Option, @@ -876,7 +904,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { }; check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?; for q in &entries { - check_switches(q, &["locked", "showCenterMark", "showCrossHairs"], "artboard.setProps")?; + check_switches(q, &["locked", "showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.setProps")?; check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?; } // Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move @@ -900,6 +928,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { let now = rect_from(q, was); let lock = q.get("locked").and_then(Value::as_bool); let (center_mark, cross_hairs) = (q.get("showCenterMark").and_then(Value::as_bool), q.get("showCrossHairs").and_then(Value::as_bool)); + let (safe_areas, pixel_aspect) = (q.get("showVideoSafeAreas").and_then(Value::as_bool), video_pixel_aspect(q, "artboard.setProps")?); // A locked artboard keeps its rectangle unless this unlocks it; its name can change. let reshaped = [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6); if reshaped && lock != Some(false) { @@ -925,6 +954,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { lock, center_mark, cross_hairs, + safe_areas, + pixel_aspect, delta, resized, fit, @@ -961,6 +992,12 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { if let Some(c) = e.cross_hairs { a.show_cross_hairs = c; } + if let Some(v) = e.safe_areas { + a.show_video_safe_areas = v; + } + if let Some(r) = e.pixel_aspect { + a.video_ruler_pixel_aspect_ratio = r; + } let id = a.id; if let Some(xf) = e.transform { let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() }; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index f74c344e7..e8f122e96 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -182,6 +182,8 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); + // Looked up for every linked node: a set, not the list (which may repeat an id many times). + let wanted: Option> = ids.map(|ids| ids.iter().copied().collect()); let mut add = |link: &LinkInfo, key: &str, document: Option, id: Option| { let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| { out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document }); @@ -193,7 +195,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { }; d.walk(|n| { if let Some((l, key, document)) = linked(n) - && ids.is_none_or(|ids| ids.contains(&n.id)) + && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) { add(l, key, document, Some(n.id)); } diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 9d417c7d9..d228c7d81 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -1334,6 +1334,8 @@ fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> { let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n); fix_ids(d, &mut g); + // Outlined type's emoji take their pixels into the document. + d.adopt_pixels(&mut g); // Outlined type repeats its object's id on its pieces. vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default()); *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 6355698b2..fe0cb1c52 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -469,7 +469,7 @@ impl Rasterized { let key = unique_key(d, "raster"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png)); let id = d.alloc_id(); - let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() }; + let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None }; let node = Node::new(id, NodeKind::Image(Box::new(image))); match self.clip { Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node), @@ -721,6 +721,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale), link: None, placement: Default::default(), + pixels: None, })); } Ok(()) @@ -788,6 +789,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), link: None, placement: Default::default(), + pixels: None, })); } Ok(()) @@ -975,6 +977,8 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); converted.push(id); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index be19d4861..c7414ed90 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -28,6 +28,7 @@ pub mod flatten; pub mod fontfiles; mod fonts; pub(crate) mod freeform; +pub mod globaledit; pub(crate) mod gradient; pub(crate) mod graph; mod halftone; @@ -79,6 +80,7 @@ mod style; pub mod stylelib; mod swatch; pub mod swatchlib; +pub mod symbollib; pub(crate) mod tabs; mod textedit; pub mod textstyles; @@ -214,6 +216,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(object::specs()); v.extend(path::specs()); v.extend(select::specs()); + v.extend(globaledit::specs()); v.extend(wand::specs()); v.extend(paint::specs()); // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command @@ -267,6 +270,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(freeform::specs()); v.extend(flatten::specs()); v.extend(stylelib::specs()); + v.extend(symbollib::specs()); v.extend(library::specs()); v.extend(expand::specs()); v.extend(attributes::specs()); diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 1c768cbdf..588307f20 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -341,6 +341,8 @@ impl DocSettings { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }) .collect(); @@ -515,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 cdc43c51f..ec91a51ed 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -380,6 +380,21 @@ pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: boo /// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no /// copy, only those anchors transform, their handles with them, and the objects selected whole. pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value, anchors: bool) -> Result { + apply_transform_moving_guides(s, label, ids, xf, p, anchors, None) +} + +/// [`apply_transform`], with the selected ruler guides moved by `guides` instead of by the +/// translation `xf` when given (a nudge along a turned Constrain Angle moves the art along it and +/// the guides along the page's axes). +fn apply_transform_moving_guides( + s: &mut Session, + label: &str, + ids: Vec, + xf: Affine, + p: &Value, + anchors: bool, + guides: Option, +) -> Result { let copy = bool_or(p, "copy", false); let areas = bool_or(p, "typeAreas", false); let objects = transforms_objects(p); @@ -387,7 +402,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf sc.patterns = transform_patterns(s, p, &ids)?; let c = xf.as_coeffs(); let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; - let delta = Vec2::new(c[4], c[5]); + let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); // A transform of ids that name no object, or an identity transform without a copy, moves @@ -564,7 +579,7 @@ fn nudge(s: &mut Session, p: &Value) -> Result { return super::docmenu::guide_move(s, &json!({ "dx": gx, "dy": gy, "copy": copy })); } let ids = selected_roots_to_transform(s)?; - apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true) + apply_transform_moving_guides(s, "Move", ids, Affine::translate((dx, dy)), p, true, Some(Vec2::new(gx, gy))) } fn about(o: Point, a: Affine) -> Affine { @@ -1299,9 +1314,9 @@ fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { } } -/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or -/// null. Any other value is an error that lists `values`. -fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { +/// Param `key` of the Align commands, Shear and `text.setFormat`'s lists: one of `values`, or +/// `None` when it is missing or null. Any other value is an error that lists `values`. +pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; match v.as_str() { Some(s) if values.contains(&s) => Ok(Some(s)), diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2f03cb27f..b99e6d9b0 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), 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\", 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)", has_doc, set_format ), @@ -258,6 +258,14 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector /// Largest indent or paragraph spacing (points; Illustrator's limit). const MAX_PARA: f64 = 1296.0; +/// `text.setFormat`'s `bullet` values and the bullets they pick. +const BULLETS: [(&str, vectorcraft_doc::text::Bullet); 4] = [ + ("dot", vectorcraft_doc::text::Bullet::Dot), + ("dash", vectorcraft_doc::text::Bullet::Dash), + ("circle", vectorcraft_doc::text::Bullet::Circle), + ("square", vectorcraft_doc::text::Bullet::Square), +]; + /// A justification spacing of `text.setFormat` (`wordSpacing`, `letterSpacing`, `glyphScaling`): /// `[min, desired, max]` or `{min?, desired?, max?}`, in percent. The values not given are kept. fn spacing_param(p: &Value, key: &str, c: &str) -> Result; 3]>> { @@ -312,6 +320,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "firstLineIndent", "spaceBefore", "spaceAfter", + "list", + "bullet", "hyphenate", "wordsLongerThan", "afterFirst", @@ -410,6 +420,12 @@ 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"])?; + 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 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")?); let single_word = match p.get("singleWordJustification") { @@ -506,6 +522,17 @@ 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); + 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 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 { + para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; + para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; + } + } if let Some(v) = flag("hyphenate") { para.hyphenate = v; } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index bfd2e5417..f898ff40e 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -2,12 +2,13 @@ //! `vectorcraft-pathops`: Offset Path, Outline Stroke, Simplify, Add Anchor Points, Split Into Grid, //! Clean Up and Divide Objects Below. +use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{AnchorRef, Appearance, Document, Node, NodeId, NodeKind, SegmentRef, TextObject}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; @@ -107,6 +108,15 @@ pub fn specs() -> Vec { has_selection, |s, _| run_pf(s, PathfinderOp::MinusBack) ), + cmd!( + "object.pathfinder.trap", + "Trap…", + ["Window", "Pathfinder"], + None, + "{ids?: [id…] (selected first; default: the selection), thickness?: pt 0.01..5000 (0.25; how far the lighter colour reaches into the darker one across vertical edges), heightWidth?: % 1..1000 (100; the reach across horizontal edges as a percentage of the vertical one), tintReduction?: % 0..100 (40; the trap paints the lighter colour at this percentage of its tint), trapsWithProcessColor?: bool (false; a swatch (spot) colour's trap is painted with its CMYK process equivalent), reverseTraps?: bool (false; the darker colour spreads into the lighter one, except a rich black)} adds overprinting traps where the selected objects' visible areas of different solid colours meet, grouped directly above the topmost selected object and selected; the objects stay as they are → {ids}", + has_selection, + trap + ), cmd!( "object.path.outlineStroke", "Outline Stroke", @@ -130,7 +140,7 @@ pub fn specs() -> Vec { "Simplify…", ["Object", "Path"], None, - "{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts, and the level used with level or auto", + "{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool, wholePaths?: bool (false: a path with segments direct-selected, or between direct-selected anchors, simplifies only each run of them between its end anchors, which stay as they are, and keeps the runs selected; true simplifies those paths whole)} → {ids, before, after} anchor counts, and the level used with level or auto", has_selection, simplify ), @@ -499,6 +509,68 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { Ok(ids_json(&ids)) } +// ---------- Trap ---------- + +/// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected +/// objects' leaf shapes, as one group of overprinting paths above the topmost selected object. +fn trap(s: &mut Session, p: &Value) -> Result { + use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps}; + const ID: &str = "object.pathfinder.trap"; + let mut q = p.clone(); + if let Some(t) = len_param(p, "thickness") { + if !(po::TRAP_MIN_WIDTH..=po::TRAP_MAX_WIDTH).contains(&t) { + return Err(bad(ID, "thickness must be from 0.01 to 5000 pt")); + } + q["thickness"] = json!(t); + } + if num_param(p, "heightWidth").is_some_and(|v| !(1.0..=1000.0).contains(&v)) { + return Err(bad(ID, "heightWidth must be from 1 to 1000 %")); + } + if num_param(p, "tintReduction").is_some_and(|v| !(0.0..=100.0).contains(&v)) { + return Err(bad(ID, "tintReduction must be from 0 to 100 %")); + } + // The effect-only settings stay at their defaults. + if let Some(o) = q.as_object_mut() { + o.remove("precision"); + o.remove("removeRedundantPoints"); + } + let options = TrapOptions::from_params(&q); + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + let ids = s.edit(TRAP_LABEL, |d, sel| { + let mut sources = vec![]; + for id in &roots { + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + each_leaf(n, &mut |l| { + if let Some((path, rule)) = node_path(l) { + sources.push(TrapSource { path, rule, fill: style_of(l).appearance.fill_paint() }); + } + }); + } + if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 { + return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into())); + } + let made = traps(&sources, &options); + if made.is_empty() { + return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into())); + } + let mut children = Vec::with_capacity(made.len()); + for piece in made { + let mut n = shape_node(d, piece.path, None); + n.appearance = trap_appearance(piece.paint).into(); + children.push(Arc::new(n)); + } + let gid = d.alloc_id(); + let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; + d.insert_beside(vec![(top, vec![Node::group(gid, children)])], false)?; + sel.set([gid]); + Ok(vec![gid]) + })?; + Ok(ids_json(&ids)) +} + // ---------- Offset Path ---------- fn join_param(p: &Value, key: &str) -> po::Join { @@ -761,12 +833,23 @@ fn path_ids(d: &Document, roots: &[NodeId]) -> Vec { out } -/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it: → (their ids, how +/// The direct selection a path keeps after an edit of part of it: (its selected anchors, and its +/// selected segments when they were picked as segments). `None` selects the path whole. +type PartSelection = Option<(BTreeSet, Option>)>; + +/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it, given its +/// direct-selected anchors and picked segments (none when it is selected whole): → (their ids, how /// many anchors they have before and after). A path that `f` would empty or returns unchanged is -/// left as it is, with its live shape and its direct-selected anchors; a replaced path loses both. -fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -> Result<(Vec, usize, usize)> { +/// left as it is, with its live shape and its direct selection; a replaced path loses its live +/// shape and has the direct selection `f` gives it. +fn edit_paths( + s: &mut Session, + label: &str, + f: impl Fn(&PathData, Option<&BTreeSet>, Option<&BTreeSet>) -> (PathData, PartSelection), +) -> Result<(Vec, usize, usize)> { let roots = selected_roots(s)?; - let d = &s.doc()?.doc; + let st = s.doc()?; + let (d, sel) = (&st.doc, &st.selection); let ids = path_ids(d, &roots); if ids.is_empty() { return Err(EngineError::Other(format!("{label}: select paths"))); @@ -775,22 +858,29 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) - for id in &ids { let Some(NodeKind::Path { path, .. }) = d.node(*id).map(|n| &n.kind) else { continue }; before += path.anchor_count(); - let np = f(path); + let (np, part) = f(path, sel.anchors.get(id), sel.segments.get(id)); if (np.is_empty() && !path.is_empty()) || np == *path { after += path.anchor_count(); continue; } after += np.anchor_count(); - changed.push((*id, np)); + changed.push((*id, np, part)); } // When no path changes, the document and its undo history stay as they are. if !changed.is_empty() { s.edit(label, |d, sel| { - for (id, np) in changed { + for (id, np, part) in changed { if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) { - // Refit and subdivision change what the old component indexes refer to. - // Keep the object selected whole so its new points remain editable. + // Refit and subdivision change what the old component indexes refer to: the + // path is selected whole, or with the part `f` says. sel.anchors.remove(&id); + sel.segments.remove(&id); + if let Some((anchors, segments)) = part.filter(|(a, _)| a.len() < np.anchor_count()) { + sel.anchors.insert(id, anchors); + if let Some(segments) = segments { + sel.segments.insert(id, segments); + } + } *path = np; *live = None; } @@ -801,6 +891,31 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) - Ok((ids, before, after)) } +/// The segments of `path` a direct selection picks: the `segments` picked as such, or else every +/// segment with both its anchors among `anchors`. `None` when the path is selected whole, or when +/// no segment of it is selected. +fn selected_segments( + path: &PathData, + anchors: Option<&BTreeSet>, + segments: Option<&BTreeSet>, +) -> Option> { + let anchors = anchors?; + let picked: BTreeSet = match segments { + Some(segs) => segs.iter().copied().filter(|&(si, i)| path.subpaths.get(si).is_some_and(|sp| i < sp.segment_count())).collect(), + None => path + .subpaths + .iter() + .enumerate() + .flat_map(|(si, sp)| { + (0..sp.segment_count()) + .filter(move |&i| anchors.contains(&(si, i)) && anchors.contains(&(si, (i + 1) % sp.anchors.len()))) + .map(move |i| (si, i)) + }) + .collect(), + }; + (!picked.is_empty()).then_some(picked) +} + /// Auto-Simplify's level for the selection: [`po::auto_simplify_level`] for the longer side of the /// selected objects' bounds. Object › Path › Simplify's slider opens at it, and /// `object.path.simplify {auto: true}` simplifies at it. @@ -826,7 +941,22 @@ fn simplify(s: &mut Session, p: &Value) -> Result { corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0), straight_lines: bool_or(p, "straightLines", false), }; - let (ids, before, after) = edit_paths(s, "Simplify", |path| po::simplify_with(path, &opts))?; + let whole = bool_or(p, "wholePaths", false); + // A path with segments direct-selected simplifies only their runs, and keeps those runs (and the + // other anchors it had selected) direct-selected. + let (ids, before, after) = edit_paths(s, "Simplify", |path, anchors, segments| { + let Some(picked) = selected_segments(path, anchors, segments).filter(|_| !whole) else { + return (po::simplify_with(path, &opts), None); + }; + let part = po::simplify_part(path, &picked, &opts); + let mut kept: BTreeSet = anchors.into_iter().flatten().filter_map(|a| part.anchors.get(a).copied()).collect(); + for &(si, i) in &part.segments { + let m = part.path.subpaths.get(si).map_or(1, |sp| sp.anchors.len().max(1)); + kept.extend([(si, i), (si, (i + 1) % m)]); + } + let segs = segments.map(|_| part.segments.clone()); + (part.path, Some((kept, segs))) + })?; let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after }); if let Some(l) = level { out["level"] = json!(l); @@ -835,7 +965,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { } fn add_anchor_points(s: &mut Session, _: &Value) -> Result { - let (ids, before, after) = edit_paths(s, "Add Anchor Points", po::add_anchor_points)?; + let (ids, before, after) = edit_paths(s, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after })) } diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 0b713341a..b25b427d3 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -737,7 +737,8 @@ fn repeat_expand(s: &mut Session, _: &Value) -> Result { /// instance node gets a fresh id. fn expand_repeat(d: &mut Document, r: NodeId) -> Result<()> { let Some(n) = d.node(r).cloned() else { return Ok(()) }; - let mut g = vectorcraft_doc::live::expanded_group(&n, None); + let hooks = vectorcraft_doc::live::Hooks { symbols: Some(d), ..Default::default() }; + let mut g = vectorcraft_doc::live::expanded_group_hooks(&n, hooks); if let Some(ch) = g.children_mut() { for c in ch.iter_mut() { fix_ids(d, Arc::make_mut(c)); diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index cc9867dc5..49924c5c8 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -159,7 +159,11 @@ 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> { - let bytes = fileio::read_file(&p.link.path).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()?; + if bytes.len() > MAX_FILE { + return None; + } if p.link.hash.as_ref().is_some_and(|h| *h != hash_bytes(&bytes)) { return None; } @@ -186,7 +190,10 @@ pub(crate) struct Source { pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result { // The same document placed the same way is read once (placing it again is free). let key = key_for(&hash_bytes(bytes), page, bounding, page_box); - if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() { + // Resolving its links depends on where the file is and on what the caller may read, so a hit + // needs the same path, and nothing is shared with or from a confined (automation) caller. + let confined = crate::file_access::current().is_some(); + if !confined && let Some((_, s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() { return Ok(s); } let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?; @@ -206,11 +213,13 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo // Drawing it needn't read it again. vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings }; - let mut m = lock_memo(); - if m.len() >= MEMO_SIZE { - m.remove(0); + if !confined { + let mut m = lock_memo(); + if m.len() >= MEMO_SIZE { + m.remove(0); + } + m.push((path.map(str::to_string), s.clone())); } - m.push(s.clone()); Ok(s) } @@ -218,9 +227,9 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo const MEMO_SIZE: usize = 32; /// Files read lately for placing, by key. -static MEMO: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +static MEMO: std::sync::Mutex, Source)>> = std::sync::Mutex::new(Vec::new()); -fn lock_memo() -> std::sync::MutexGuard<'static, Vec> { +fn lock_memo() -> std::sync::MutexGuard<'static, Vec<(Option, Source)>> { // Entries are only pushed and removed: a panic while held leaves it whole. MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 57593e116..64e6c19e1 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -385,6 +385,7 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { xf: Affine::scale_non_uniform(sx, sy), link, placement: Default::default(), + pixels: None, }; Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) } diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index a138526ee..ecddf7cae 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -350,7 +350,12 @@ fn load_folder(folder: &str) -> Value { return json!({"folder": folder, "error": e}); } // A file in it may be a link to one elsewhere. - match registry::load_folder_where(std::path::Path::new(folder), |f| crate::file_access::check_read(&f.to_string_lossy())) { + match registry::load_folder_where(std::path::Path::new(folder), |f| match f.to_str() { + Some(f) => crate::file_access::check_read(f), + // A lossy name would check another file than the one loaded. + None if crate::file_access::current().is_none() => Ok(()), + None => Err(format!("automation path rejected: {} isn't valid text", f.display())), + }) { Ok(r) => { json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index b8b0beb5c..907e8fcd6 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -234,6 +234,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("anchorPathLabels", "Smart Guides", "Display Options", "Anchor/Path Labels", bool), p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool), p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool), + p!("distanceGuides", "Smart Guides", "Display Options", "Invoke Distance Guides", bool), p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")), p!("snapTangent", "Smart Guides", "Geometric Guides", "Snap to Tangent", bool), p!("snapPerpendicular", "Smart Guides", "Geometric Guides", "Snap to Perpendicular", bool), @@ -471,13 +472,14 @@ impl Prefs { /// The folders the app reads (fonts, plug-ins) or writes (Data Recovery copies, the Templates /// folder it makes for its dialogs) on its own, with the access each needs. - fn folders_mut(&mut self) -> [(&mut String, crate::file_access::Access); 4] { + fn folders_mut(&mut self) -> [(&mut String, &'static [crate::file_access::Access]); 4] { use crate::file_access::Access; [ - (&mut self.fonts_folder, Access::Read), - (&mut self.plugins_folder, Access::Read), - (&mut self.recovery_folder, Access::Write), - (&mut self.templates_folder, Access::Write), + (&mut self.fonts_folder, &[Access::Read][..]), + (&mut self.plugins_folder, &[Access::Read][..]), + // Data Recovery lists and restores what is in its folder too. + (&mut self.recovery_folder, &[Access::Write, Access::Read][..]), + (&mut self.templates_folder, &[Access::Write][..]), ] } @@ -493,7 +495,7 @@ impl Prefs { if folder.is_empty() || folder == old.trim() { continue; } - if let Err(e) = roots.check(folder, access) { + if let Err(e) = access.iter().try_for_each(|a| roots.check(folder, *a)) { refused.push(e); *new = old.clone(); } diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index bd9fce789..301f696df 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -96,7 +96,7 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b let key = unique_key(&out, "print-bitmap"); out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?)); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; + let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(Box::new(image))))); } let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0)); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 4f2eeed42..ae88da1fa 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -225,7 +225,15 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); let mut image = Node::new( id, - NodeKind::Image(Box::new(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key, + width: img.width, + height: img.height, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); image.name = Some("Raster effect".into()); Some(image) diff --git a/crates/engine/src/cmd/rasterfx/report.rs b/crates/engine/src/cmd/rasterfx/report.rs index e263b03fc..f26db6db6 100644 --- a/crates/engine/src/cmd/rasterfx/report.rs +++ b/crates/engine/src/cmd/rasterfx/report.rs @@ -92,6 +92,7 @@ mod tests { xf: Affine::scale(72.0 / ppi), link: None, placement: Default::default(), + pixels: None, })), ) } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 8ec804c13..8a7d220e3 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "Select Segments", [], None, - "{id, segments: [[subpath, segment]…]} select path segments; their endpoints move with them, Delete removes only their connections", + "{id, segments: [[subpath, segment]…], mode?: \"set\"|\"add\"|\"toggle\"} select path segments; their endpoints move with them, Delete removes only their connections", has_doc, segments ), @@ -436,22 +436,85 @@ pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result, cmd: &str) -> Result> { + let path = doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "object is not a path"))?; + let mut endpoints = BTreeSet::new(); + for &(si, seg) in refs { + let sp = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()).ok_or_else(|| bad(cmd, "segment does not exist"))?; + let n = sp.anchors.len().max(1); + endpoints.extend([(si, seg), (si, (seg + 1) % n)]); + } + Ok(endpoints) +} + fn segments(s: &mut Session, p: &Value) -> Result { const C: &str = "select.segments"; let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?; let refs: BTreeSet<_> = checked_refs(p.get("segments"), C)?.into_iter().collect(); - let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "object is not a path"))?; - let mut endpoints = BTreeSet::new(); - for &(si, seg) in &refs { - let sp = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()).ok_or_else(|| bad(C, "segment does not exist"))?; - endpoints.extend([(si, seg), (si, (seg + 1) % sp.anchors.len())]); - } - s.select(|_, sel| { - sel.clear(); - if !refs.is_empty() { - sel.add(id); - sel.anchors.insert(id, endpoints); - sel.segments.insert(id, refs); + let mode = match str_param(p, "mode") { + None | Some("set") => "set", + Some("add") => "add", + Some("toggle") => "toggle", + Some(m) => return Err(bad(C, format!("unknown mode {m:?}"))), + }; + let endpoints = segment_endpoints(&s.doc()?.doc, id, &refs, C)?; + s.select(|d, sel| { + if mode == "set" { + sel.clear(); + if !refs.is_empty() { + sel.add(id); + sel.anchors.insert(id, endpoints); + sel.segments.insert(id, refs); + } + } else if mode == "add" { + if !refs.is_empty() { + sel.add(id); + sel.anchors.entry(id).or_default().extend(endpoints); + sel.segments.entry(id).or_default().extend(refs); + } + } else if mode == "toggle" { + let seg_entry = sel.segments.entry(id).or_default(); + let mut removed_endpoints = BTreeSet::new(); + let mut added_endpoints = BTreeSet::new(); + let path = d.node(id).and_then(|n| n.path_data()); + for &(si, seg) in &refs { + let Some(sp) = path.as_ref().and_then(|p| p.subpaths.get(si)) else { continue }; + let n = sp.anchors.len().max(1); + let end1 = (si, seg); + let end2 = (si, (seg + 1) % n); + if seg_entry.contains(&(si, seg)) { + seg_entry.remove(&(si, seg)); + removed_endpoints.insert(end1); + removed_endpoints.insert(end2); + } else { + seg_entry.insert((si, seg)); + added_endpoints.insert(end1); + added_endpoints.insert(end2); + } + } + let mut surviving_seg_endpoints = BTreeSet::new(); + for &(si, seg) in seg_entry.iter() { + if let Some(sp) = path.as_ref().and_then(|p| p.subpaths.get(si)) { + let n = sp.anchors.len().max(1); + surviving_seg_endpoints.insert((si, seg)); + surviving_seg_endpoints.insert((si, (seg + 1) % n)); + } + } + let anchor_entry = sel.anchors.entry(id).or_default(); + for pt in removed_endpoints { + if !surviving_seg_endpoints.contains(&pt) { + anchor_entry.remove(&pt); + } + } + anchor_entry.extend(added_endpoints); + if seg_entry.is_empty() { + sel.segments.remove(&id); + } + if anchor_entry.is_empty() { + sel.remove(id); + } else { + sel.add(id); + } } })?; ok() @@ -472,8 +535,7 @@ fn anchors(s: &mut Session, p: &Value) -> Result { sel.clear(); } // A path selected as a whole has every anchor selected: toggle and add start from them all, - // but only when no partial anchor selection is active and only on a single object. With - // several objects selected, a click picks that anchor alone, so Join can take two picked ends. + // but only when no partial anchor selection is active and only on a single isolated object. let whole = sel.contains(id) && sel.partial(id).is_none() && sel.anchors.is_empty() && sel.objects.len() <= 1; sel.add(id); // A segment pick's endpoints aren't selected anchors: start from none of them. @@ -491,6 +553,9 @@ fn anchors(s: &mut Session, p: &Value) -> Result { e.insert(r); } } + if e.is_empty() { + sel.remove(id); + } })?; ok() } @@ -561,7 +626,7 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result { /// Select › Same › Fill/Stroke Color: is `a` the same colour as `b`? Tints of one global or spot /// swatch are, unless `tint` (General › Select Same Tint %) asks for the same tint too. -fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool { +pub(crate) fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool { match (a, b) { (Paint::Solid { swatch: Some(x), tint: ta, .. }, Paint::Solid { swatch: Some(y), tint: tb, .. }) if x == y => !tint || (ta - tb).abs() < 1e-4, _ => a == b, diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs new file mode 100644 index 000000000..5b361df6a --- /dev/null +++ b/crates/engine/src/cmd/symbollib.rs @@ -0,0 +1,346 @@ +//! Symbol libraries (Window → Symbol Libraries, the Symbols panel's library button): read-only sets +//! of symbols the library panel shows. A library is a document holding the symbols with the +//! resources they use (nested symbols, patterns, swatches, brushes, graphic styles, images) and +//! their Symbol Options; saved, it is a native `.vectorcraft` file, which File › Open also opens. +//! The built-in library is generated here ([`BUILTIN`]); User Defined ones are the `.vectorcraft` +//! files in the user library folder ([`Libraries`]), and Other Library… loads the symbols of any +//! document VectorCraft opens. `symbol.addFromLibrary` copies symbols into the document; +//! `symbol.saveLibrary` writes the document's. + +use std::collections::HashSet; +use std::sync::{Arc, LazyLock}; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; + +use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_natural_size}; +use super::clipboard::Clipboard; +use super::clipboard::resources::SwatchChoices; +use super::swatch::str_list; +use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; +use super::*; + +/// The extension of saved symbol libraries: native documents. +pub const SYMBOLS_EXT: &str = vectorcraft_format::EXTENSION; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "symbol.libraries", + "Symbol Libraries", + [], + None, + "{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the .vectorcraft files in the user library folder, rescanned now)|\"loaded\" (symbol.loadLibrary), count, path?}], userFolder} every symbol library the library panel opens, in menu order", + always, + list + ), + cmd!( + query "symbol.library", + "Symbol Library", + [], + None, + "{library: id or name} → {id, name, category, symbols: [{name, size: [w, h], registration: 0..8, exportType}] (as symbol.list)}", + always, + get + ), + cmd!( + "symbol.addFromLibrary", + "Add to Symbols", + ["Window", "Symbol Libraries"], + None, + "{library: id or name, name? | names?: [symbol names in the library] (default: all of it), place?: false (also place an instance of the first one, its registration point at x, y: default the first artboard's centre), x?, y?, swatchConflict? (as edit.paste)} copy library symbols into the document's Symbols as one undo step (with the place), with their Symbol Options and the resources they use (the symbols they nest, patterns, swatches, brushes, graphic styles, images). A symbol the document already has (same name and art) isn't added again; a name another symbol has gets a number (\"Star 2\"). The first one becomes the current symbol → {library, names: [each symbol's name in the document], added: [names added], existing: [names already there], placed?: id}", + has_doc, + add + ), + cmd!( + "symbol.saveLibrary", + "Save Symbol Library…", + ["Window", "Symbols"], + None, + "{path?, names?: [symbol names] (default: all), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's symbols, with their Symbol Options and the resources they use, as a symbol library: a .vectorcraft document holding only them → {path, count, library?: id when saved to the user folder}; without path or user → {dataBase64: the file's bytes, count}", + has_doc, + save + ), + cmd!( + "symbol.loadLibrary", + "Other Library…", + ["Window", "Symbol Libraries"], + None, + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load the symbols of any document VectorCraft opens (see document.formats), a saved symbol library included, for the library panel (Window → Symbol Libraries lists it until the app quits; a file of the user library folder, or a file with the same bytes as one there, opens as that User Defined library) → {library: id, name, count}", + always, + load + ), + ] +} + +/// A symbol library: its name and a document holding its symbols (in panel order) with the +/// resources they use and their Symbol Options; the document has no art of its own. +#[derive(Clone, Debug)] +pub struct SymbolLibrary { + pub name: String, + pub doc: Document, +} + +impl Default for SymbolLibrary { + fn default() -> Self { + Self { name: String::new(), doc: Document::new(100.0, 100.0) } + } +} + +impl SymbolLibrary { + pub fn len(&self) -> usize { + self.doc.symbols.len() + } + pub fn is_empty(&self) -> bool { + self.doc.symbols.is_empty() + } + pub fn symbol(&self, name: &str) -> Option<&Symbol> { + self.doc.symbols.iter().find(|s| s.name == name) + } + + /// Document `src`'s symbols `names` (empty: all of them, in panel order) as a library named + /// `name`: the symbols, the symbols they nest and every other resource they use, with their + /// Symbol Options. + pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result { + if let Some(n) = names.iter().find(|n| !src.symbols.iter().any(|s| s.name == **n)) { + return Err(format!("no symbol `{n}`")); + } + let picked = src.symbols.iter().filter(|s| names.is_empty() || names.contains(&s.name)); + let clip = Clipboard::gather(src, picked.map(|s| instance(&s.name)).collect()); + let mut doc = clip.to_document(); + for l in &mut doc.layers { + if let Some(ch) = Arc::make_mut(l).children_mut() { + ch.clear(); + } + } + // The panel order of the document they came from. + let order = |n: &str| src.symbols.iter().position(|s| s.name == n); + doc.symbols.sort_by_key(|s| order(&s.name)); + for s in &doc.symbols.clone() { + copy_options(src, &s.name, &mut doc, &s.name); + } + doc.title = name.clone(); + Ok(Self { name, doc }) + } +} + +impl LibraryFile for SymbolLibrary { + const EXTS: &'static [&'static str] = &[SYMBOLS_EXT]; + fn read(bytes: &[u8], stem: &str) -> std::result::Result { + let doc = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?; + SymbolLibrary::from_document(&doc, &[], stem.to_string()) + } + fn name(&self) -> &str { + &self.name + } +} + +/// User Defined and loaded symbol libraries. +pub type Libraries = super::swatchlib::Libraries; + +/// A library generated in code. +pub struct BuiltinSymbolLibrary { + pub id: &'static str, + pub name: &'static str, + make: fn() -> SymbolLibrary, +} + +/// The built-in symbol libraries, in menu order: original art generated in code. +pub const BUILTIN: &[BuiltinSymbolLibrary] = &[BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes }]; + +/// Built-in library `id`, made once. +fn builtin_library(id: &str) -> Option> { + static MADE: LazyLock>> = LazyLock::new(|| BUILTIN.iter().map(|b| Arc::new((b.make)())).collect()); + BUILTIN.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned()) +} + +/// Basic Shapes: plain filled and outlined shapes, 40 pt (the arrow 48 × 32 pt). +fn basic_shapes() -> SymbolLibrary { + let square = Rect::new(-20.0, -20.0, 20.0, 20.0); + let c = Point::ZERO; + let arrow = [(-24.0, -6.0), (4.0, -6.0), (4.0, -16.0), (24.0, 0.0), (4.0, 16.0), (4.0, 6.0), (-24.0, 6.0)]; + let arrow: Vec = arrow.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let shapes = [ + ("Circle", shapes::ellipse(square), "#2f80c9"), + ("Square", shapes::rectangle(square), "#d9534f"), + ("Rounded Square", shapes::rounded_rectangle(square, 8.0), "#f0ad4e"), + ("Triangle", shapes::polygon(c, 22.0, 3, 0.0), "#5cb85c"), + ("Diamond", shapes::polygon(c, 20.0, 4, 0.0), "#8e5bc6"), + ("Hexagon", shapes::polygon(c, 20.0, 6, 0.0), "#1fa39b"), + ("Star", shapes::star(c, 21.0, 9.0, 5, 0.0), "#f2c233"), + ("Burst", shapes::star(c, 21.0, 15.0, 12, 0.0), "#e8743b"), + ("Arrow", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&arrow, true)), "#4a5a6a"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in shapes { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + // The outline: the fill, darkened. + let edge = fill.lerp(&Color::BLACK, 0.4); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.0)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Basic Shapes".into(); + SymbolLibrary { name: "Basic Shapes".into(), doc } +} + +/// An instance of symbol `name` (what the resource gathering follows). +fn instance(name: &str) -> Node { + Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) +} + +/// Symbol `from`'s Symbol Options in `src` (natural size, registration, export type, 9-slice +/// guides) given to symbol `to` of `d`. +fn copy_options(src: &Document, from: &str, d: &mut Document, to: &str) { + set_natural_size(d, to, Some(natural_size(src, from))); + d.set_symbol_registration(to, src.symbol_registration(from)); + d.set_symbol_export_type(to, src.symbol_export_type(from)); + d.set_symbol_nine_slice(to, src.symbol_nine_slice(from)); +} + +fn builtin_info(b: &BuiltinSymbolLibrary) -> LibraryInfo { + LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" } +} + +/// Every symbol library, in menu order: built-in, User Defined, loaded. +pub fn libraries(s: &Session) -> Vec { + BUILTIN.iter().map(builtin_info).chain(s.symbol_libraries.infos().cloned()).collect() +} + +/// Library `key` (an id, or a name in any case) with its info. +pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { + if let Some(found) = s.symbol_libraries.get(key) { + return Some(found); + } + if let Some(b) = BUILTIN.iter().find(|b| b.id == key) { + return Some((builtin_info(b), builtin_library(key)?)); + } + let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id; + library(s, &id) +} + +fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc)> { + let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see symbol.libraries)"))?; + library(s, key).ok_or_else(|| bad(cmd, format!("no symbol library `{key}` (see symbol.libraries)"))) +} + +fn list(s: &mut Session, _: &Value) -> Result { + s.symbol_libraries.rescan(); + let libs: Vec = libraries(s) + .into_iter() + .map(|l| { + let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.symbol_libraries.path(&l.id)}) + }) + .collect(); + Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.user_dir() })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let (info, lib) = library_param(s, p, "symbol.library")?; + let d = &lib.doc; + let symbols: Vec = d + .symbols + .iter() + .map(|sym| { + let (w, h) = natural_size(d, &sym.name); + json!({"name": sym.name, "size": [w, h], "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id()}) + }) + .collect(); + Ok(json!({"id": info.id, "name": info.name, "category": info.category, "symbols": symbols})) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.addFromLibrary"; + let (info, lib) = library_param(s, p, C)?; + let mut names = str_list(p, "names"); + names.extend(str_param(p, "name").map(str::to_string)); + if let Some(n) = names.iter().find(|n| lib.symbol(n).is_none()) { + return Err(bad(C, format!("no symbol `{n}` in `{}`", lib.name))); + } + let mut picked: Vec = if names.is_empty() { lib.doc.symbols.iter().map(|s| s.name.clone()).collect() } else { names }; + // Each once, in the order asked for. + let mut seen = HashSet::new(); + picked.retain(|n| seen.insert(n.clone())); + let place = bool_or(p, "place", false); + if place && picked.is_empty() { + return Err(bad(C, format!("`{}` has no symbols to place", lib.name))); + } + let choices = SwatchChoices::parse(C, p)?; + let clip = Clipboard::gather(&lib.doc, picked.iter().map(|n| instance(n)).collect()); + let st = s.doc()?; + let before: HashSet = st.doc.symbols.iter().map(|s| s.name.clone()).collect(); + // A library symbol's name in the document: its own, or the one the import gave it. + let target = |renamed: &[Value], n: &str| -> String { + let to = renamed.iter().find(|r| r["kind"] == "symbol" && r["from"] == n).and_then(|r| r["to"].as_str()); + to.unwrap_or(n).to_string() + }; + // Where the symbols land, worked out on a copy: nothing to add and nothing to place is no edit. + let mut probe = (*st.doc).clone(); + let imported = clip.import_into(&mut probe, &choices, false); + let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); + let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); + let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None }; + let parent = if place { st.target_parent()? } else { None }; + let mut out = json!({ "library": info.id }); + if imported.added > 0 || place { + let placed = s.edit("Add to Symbols", |d, sel| { + let imported = clip.import_into(d, &choices, false); + // Every symbol brought in (nested ones too) takes its Symbol Options along. + for sym in &clip.symbols { + let to = target(&imported.renamed, &sym.name); + if !before.contains(&to) { + copy_options(&lib.doc, &sym.name, d, &to); + } + } + let Some(first) = doc_names.first() else { return Ok(None) }; + d.unknown.insert(CURRENT_SYMBOL.into(), json!(first)); + let Some(at) = at else { return Ok(None) }; + let xf = place_xf(at, natural_size(d, first), d.symbol_registration(first)); + let id = d.alloc_id(); + d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: first.clone(), xf }))?; + sel.set([id]); + Ok(Some(id)) + })?; + if let Some(id) = placed { + out["placed"] = json!(id.0); + } + } else if let Some(first) = doc_names.first() { + s.execute("symbol.setCurrent", &json!({ "name": first }))?; + } + let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n)); + out["names"] = json!(doc_names); + out["added"] = json!(added); + out["existing"] = json!(existing); + Ok(out) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.saveLibrary"; + let st = s.doc()?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); + let lib = SymbolLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?; + if lib.is_empty() { + return Err(bad(C, "the document has no symbols to save")); + } + let mut out = json!({ "count": lib.len() }); + let bytes = vectorcraft_format::save_file(&lib.doc); + s.symbol_libraries.write(p, &lib.name, SYMBOLS_EXT, FileData::Binary(bytes), &mut out, C)?; + Ok(out) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.loadLibrary"; + let (info, lib) = s.symbol_libraries.load(p, C, |bytes, file| { + let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?; + if loaded.preview_only { + return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); + } + SymbolLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e)) + })?; + Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) +} diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 45532031c..0c29ac4e7 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -715,7 +715,8 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { if dx != 0.0 && g.rtl { return Err(bad(C, "a right-to-left character moves only up and down (dy)")); } - let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.byte + h.len == a); + // A list marker's glyphs (length 0) stand for no character. + let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.len > 0 && h.byte + h.len == a); let pr = prev.map(|h| (h.byte, h.byte + h.len)); let em = glyph_em(&t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?; let em_prev = match prev { @@ -771,7 +772,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { let mut trial = t.clone(); touch(&mut trial, None, None, to - shift); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - l.glyphs.iter().find(|h| h.byte == a).map(|h| h.xf * Point::ZERO) + l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO) }; let per_step = match (pen_at(from + step), pen_at(from + 2.0 * step)) { (Some(p), Some(q)) => linear(t.xf, q - p).hypot() / step.abs(), @@ -788,7 +789,9 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // out alone, in point type of the same orientation, before and after the change. let grown = { let mut alone = TextObject { kind: TextKind::Point, runs: edit::slice_runs(&t.runs, a, b), paras: Vec::new(), ..t.clone() }; - let advance = |o: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().map(|h| h.advance).sum::(); + let advance = |o: &TextObject| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().filter(|h| h.len > 0).map(|h| h.advance).sum::() + }; let old = advance(&alone); edit::style_range(&mut alone.runs, 0, b - a, |st| change.apply(st)); (advance(&alone) - old) * lay.fit_scale @@ -796,15 +799,15 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // The character lands `moved` further on, and the character after it on its line keeps its // distance from it, wider or narrower only by the character's new scales. let goal = pen(g) + moved; - let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.line == g.line).map(|h| pen(h) + grown); + let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.len > 0 && h.line == g.line).map(|h| pen(h) + grown); // How far the character and the character after it on its line miss their places, along the // line, with the kerning `k` (before, own) added. let miss = |k: (Option, Option)| { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - let h = l.glyphs.iter().find(|h| h.byte == a)?; - let next = match (l.glyphs.iter().find(|n| n.byte == b && n.line == h.line), next_goal) { + let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0)?; + let next = match (l.glyphs.iter().find(|n| n.byte == b && n.len > 0 && n.line == h.line), next_goal) { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, }; @@ -1214,6 +1217,7 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result { st.doc = created.doc.clone(); st.history.undo.truncate(k); st.selection.prune(&st.doc); + super::globaledit::follow_history(st); st.revision += 1; return Ok(json!({"removed": true})); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index cf3585f59..680024832 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -168,7 +168,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { /// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's /// JSON leaves out at their defaults (the Hyphenation and Justification options, the Every-line Composer, no tab -/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters. +/// stops, no list, Auto direction…) are written out, as [`char_attrs`] does for characters. pub fn para_attrs(v: &ParaStyle) -> Map { let mut m = to_attrs(v); for (k, x) in [ @@ -176,6 +176,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("justification", json!(v.justification)), ("composer", json!(v.composer)), ("tabs", json!(v.tabs)), + ("list", json!(v.list)), ("mojikumi", json!(v.mojikumi)), ("direction", json!(v.direction)), ("leading_model", json!(v.leading_model)), @@ -910,6 +911,7 @@ mod tests { /// resets the type that uses it. #[test] fn variable_font_settings_are_captured_explicitly() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let (mut s, source) = session_with_text("Source"); @@ -933,6 +935,7 @@ mod tests { /// version and Variable Font settings, unless the style sets them. #[test] fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let (mut s, id) = session_with_text("Source"); diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 273707a12..e1583e129 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { "Thread Text", [], None, - "{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread: closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected", + "{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected", has_doc, add ), @@ -81,7 +81,8 @@ thread_local! { } /// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing -/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones. +/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones; a +/// frame that is now point type leaves its thread with the text it holds. pub(crate) fn reflow(before: &Document, doc: &mut Document) { let threads = std::mem::take(&mut doc.text_threads); let mut kept = vec![]; @@ -116,6 +117,8 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { sources.push(t.clone()); live.push(*id); } + // A frame converted to point type keeps the text it holds and leaves the thread. + (None, Some(_)) if matches!(doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_))) => {} (None, Some(old)) => { story.extend(old.runs.iter().cloned()); later_wins.push(false); @@ -260,11 +263,12 @@ fn create(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>() })) } -/// `text.thread.add`: the out port's loaded text, placed. +/// `text.thread.add`: the loaded text of a port, placed. fn add(s: &mut Session, p: &Value) -> Result { const C: &str = "text.thread.add"; let given = |k: &str| p.get(k).filter(|v| !v.is_null()); let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?; + let before = switch_or(p, "before", false, C)?; let d = &s.doc()?.doc; let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type or type on a path"))?; let style = src.first_style(); @@ -327,9 +331,10 @@ fn add(s: &mut Session, p: &Value) -> Result { }; match d.text_threads.iter_mut().find(|t| t.contains(&from)) { Some(t) => { - let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1); + let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| if before { i } else { i + 1 }); t.insert(at, to); } + None if before => d.text_threads.push(vec![to, from]), None => d.text_threads.push(vec![from, to]), } // Force a re-flow of the thread on this edit: the edit leaves existing area type `to` @@ -615,6 +620,35 @@ mod tests { assert_eq!(threads(&s), [[a, c, d]]); } + /// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes + /// before the frame it was loaded from, so the story starts there and flows on into it; in the + /// middle of a thread the new frame goes in between, each in one undo step. + #[test] + fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() { + let (mut s, a, b) = setup(); + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, a]]); + assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click"); + assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c)); + assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4)); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]); + // In between the frame and the one before it. + let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, d, a]]); + assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[c, a]]); + // A closed path clicked with the in port's text comes first, with the start of the story. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b); + assert_eq!(threads(&s), [[b, a]]); + assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty()); + assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4)); + // `before: false` is the out port's. + let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap()); + assert_eq!(threads(&s), [[b, a, e]]); + } + /// Type on a path along a line beside the area type of [`setup`]. fn type_on_a_line(s: &mut Session) -> u64 { let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap()); @@ -689,6 +723,8 @@ mod tests { json!({"from": a, "rect": [0, 0, 10, 0]}), json!({"from": a, "rect": [0, 0, 10]}), json!({"from": a, "rect": "big"}), + json!({"from": a, "at": [0, 0], "before": "yes"}), + json!({"from": a, "at": [0, 0], "before": 1}), ] { assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted"); assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document"); @@ -714,6 +750,55 @@ mod tests { assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); } + /// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`], + /// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`. + fn thread_of(n: usize) -> (Session, Vec) { + let (mut s, a, b) = setup(); + let frames = if n == 3 { + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); + vec![a, c, b] + } else { + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + vec![a, b] + }; + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); + (s, frames) + } + + /// Convert To Point Type on a frame of threaded text takes it out of its thread with the text + /// it holds, and the other frames keep the text they hold: the story holds each character once. + /// A thread left with one frame is no thread. Undo threads the frame again. + #[test] + fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() { + // Soft wraps of the point type become line breaks, so its characters are compared without + // white space. + let squeeze = |t: &str| t.split_whitespace().collect::(); + for n in [2, 3] { + for k in 0..n { + let (mut s, frames) = thread_of(n); + let shown: Vec = frames.iter().map(|id| text(&s, *id)).collect(); + let id = frames[k]; + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") }; + assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type"); + assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text"); + for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) { + assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text"); + } + let rest: Vec = frames.iter().copied().filter(|f| *f != id).collect(); + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}"); + let all: String = frames.iter().map(|f| text(&s, *f)).collect(); + assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::>(), shown, "{n} frames, frame {k}: undo"); + } + } + } + #[test] fn release_and_remove() { let (mut s, a, b) = setup(); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4c45df5bd..b8314b1dc 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{AreaFit, Document, ImageObject, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject}; +use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject}; use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; @@ -559,18 +559,10 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf }))); continue; } - // Colour bitmap glyphs (emoji) become embedded images, in place; glyphs that show - // the same image share its pixels, under a key of their own if another image has - // taken theirs. - if let Some((image, to_text)) = g.placed_image() { - let mut key = format!("glyph-{}", image.blob.content_key()); - if d.images.get(&key).is_some_and(|b| b.bytes != image.blob.bytes) { - key = super::menucmds::unique_key(d, "glyph"); - } - d.images.entry(key.clone()).or_insert_with(|| image.blob.clone()); - let im = - ImageObject { key, width: image.width, height: image.height, xf: t.xf * to_text, link: None, placement: Default::default() }; - children.push(Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))))); + // 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. @@ -1441,6 +1433,92 @@ mod area_tests { assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); } + /// Paragraph panel › Bullets: `text.setFormat {list, bullet}` makes the paragraphs it touches a + /// bulleted list and refuses unknown values. A paragraph that joins a list with no left or + /// first-line indent gets 18 pt and -18 pt, unless the call sets an indent, and one with indents + /// keeps them. The list is saved only when set and opens with the file, Return continues it, + /// "none" turns it off, and paragraph styles set it and turn it off. + #[test] + fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let indents = |s: &Session, i: usize| (text(s).para_at(i).left_indent, text(s).para_at(i).first_line_indent); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!(list(&s, 0), None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("list").is_none(), "no list is saved without one"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"start": 4, "end": 4, "leftIndent": 30, "firstLineIndent": 5})).unwrap(); + // The first two paragraphs: the first gets the hanging indent, the second keeps its indents. + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 4})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dot, dot, None)); + assert_eq!((indents(&s, 0), indents(&s, 1), indents(&s, 2)), ((18.0, -18.0), (30.0, 5.0), (0.0, 0.0))); + // `bullet` picks the bullet, and `list: "bullet"` keeps it; the indents stay as they are. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 0})).unwrap(); + let square = Some(ParaList::bulleted(Bullet::Square)); + assert_eq!((list(&s, 0), indents(&s, 0)), (square, (18.0, -18.0))); + for bad in [json!({"list": "numbers"}), json!({"bullet": "star"}), json!({"list": 1}), json!({"bullet": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!((list(&s, 0), list(&s, 2)), (square, None), "an unknown value changes nothing"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); + // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. + let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); + // Return at the end of a list item starts another one. + s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 3, "insert": "\nOne more"})).unwrap(); + assert_eq!((list(&s, 1), indents(&s, 1)), (square, (18.0, -18.0))); + s.execute("edit.undo", &json!({})).unwrap(); + // "none" turns the list off and leaves the indents. + s.execute("text.setFormat", &json!({"bullet": "none", "start": 0, "end": 0})).unwrap(); + assert_eq!((list(&s, 0), indents(&s, 0)), (None, (18.0, -18.0))); + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + assert_eq!(list(&s, 1), None); + // Indents the call sets win over the hanging indent. + s.execute("text.setFormat", &json!({"list": "bullet", "leftIndent": 0, "firstLineIndent": 0, "start": 8, "end": 8})).unwrap(); + assert_eq!((list(&s, 2), indents(&s, 2)), (dot, (0.0, 0.0))); + // A style that holds a list sets it; a style captured from a paragraph without one turns it off. + s.execute("paraStyle.new", &json!({"name": "Listed", "attrs": {"list": {"bullet": "dash"}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Listed", "id": id})).unwrap(); + let dash = Some(ParaList::bulleted(Bullet::Dash)); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dash, dash, dash)); + let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); + } + + /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. + #[test] + fn create_outlines_makes_a_path_of_the_bullet() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let outlined = |s: &mut Session, list: bool| { + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One"})).unwrap()["id"].as_u64().unwrap(); + if list { + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + } + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + s.doc().unwrap().doc.node(NodeId(g)).unwrap().children().unwrap().len() + }; + let plain = outlined(&mut s, false); + assert_eq!(outlined(&mut s, true), plain + 1, "one more path: the bullet"); + } + /// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and /// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their /// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They @@ -1789,6 +1867,7 @@ mod area_tests { /// when set, each value clamped to its axis's range, and cleared by another font or style. #[test] fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let mut s = Session::new(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 1eca1b756..a61707b00 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Convert To Point Type", ["Type"], None, - "{ids?|id?} area type → point type; soft line wraps become line breaks → {ids}", + "{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}", has_selection, to_point ), diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index bc821a357..993006513 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -252,8 +252,9 @@ fn resolve(path: &str) -> Result { } } -/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on -/// Windows, whose file systems ignore it)? +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case in a +/// Windows drive prefix only: resolved folder names have their real case, and a folder that differs +/// in case alone may be another, on a case-sensitive Windows folder)? fn inside(path: &Path, root: &Path) -> bool { let mut path = path.components(); root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) @@ -263,7 +264,7 @@ fn same_component(a: Component, b: Component) -> bool { if a == b { return true; } - if !cfg!(windows) { + if !cfg!(windows) || !matches!((a, b), (Component::Prefix(_), Component::Prefix(_))) { return false; } match (a.as_os_str().to_str(), b.as_os_str().to_str()) { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index ea1f525e9..d0a0baadd 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -129,18 +129,26 @@ pub fn document(s: &Session) -> Value { } /// An artboard's name and rectangle, its background color (`#rrggbb`) when it has one, -/// `locked: true` when it is locked, and `showCenterMark: true` and `showCrossHairs: true` when -/// it shows them. +/// `locked: true` when it is locked, `showCenterMark: true`, `showCrossHairs: true` and +/// `showVideoSafeAreas: true` when it shows them, and `videoRulerPixelAspectRatio` when it isn't 1. fn artboard_summary(a: &vectorcraft_doc::Artboard) -> Value { let mut v = json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()}); if let Some(c) = a.color { v["color"] = json!(c.to_hex()); } - for (key, on) in [("locked", a.locked), ("showCenterMark", a.show_center_mark), ("showCrossHairs", a.show_cross_hairs)] { + for (key, on) in [ + ("locked", a.locked), + ("showCenterMark", a.show_center_mark), + ("showCrossHairs", a.show_cross_hairs), + ("showVideoSafeAreas", a.show_video_safe_areas), + ] { if on { v[key] = json!(true); } } + if a.video_ruler_pixel_aspect_ratio != 1.0 { + v["videoRulerPixelAspectRatio"] = json!(a.video_ruler_pixel_aspect_ratio); + } v } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 48fa2e344..bec7d9d9f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -348,6 +348,9 @@ pub struct DocState { pub variables_highlight: Option, /// Isolation mode container. pub isolation: Option, + /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape + /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. + pub global_edit: Option, pub interaction: Option, /// Edits made while this is open are one undo step (a scrubbed numeric field). pub undo_group: Option, @@ -551,6 +554,7 @@ impl DocState { layer_rows: vec![], variables_highlight: None, isolation: None, + global_edit: None, interaction: None, undo_group: None, last_transform: None, @@ -644,6 +648,7 @@ impl DocState { self.doc = it.doc; self.selection = Arc::unwrap_or_clone(it.selection); self.sync_new_art(); + cmd::globaledit::follow_history(self); self.active_layer = it.active_layer; self.layer_rows = it.layer_rows; self.variables_highlight = it.variables_highlight; @@ -861,16 +866,22 @@ const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catal /// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must /// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. fn doc_sane(d: &Document, sel: &Selection) -> bool { - let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD); d.ruler_origin.is_finite() && d.ruler_origin.x.abs() <= MAX_COORD && d.ruler_origin.y.abs() <= MAX_COORD - && d.artboards.iter().all(|a| ok(a.rect)) + && d.artboards.iter().all(|a| rect_in_range(a.rect)) && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) - && { - let paths = d.index_paths(sel.objects.iter().copied()); - sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok)) - } + && objects_in_range(d, &sel.objects) +} + +fn rect_in_range(r: vectorcraft_geom::Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) +} + +/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)? +fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool { + let paths = d.index_paths(ids.iter().copied()); + ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range)) } /// Where new art goes (Illustrator's drawing modes, Shift+D cycles). @@ -970,6 +981,7 @@ pub struct Prefs { pub anchor_path_labels: bool, pub measurement_labels: bool, pub spacing_guides: bool, + pub distance_guides: bool, pub snapping_tolerance: f64, pub snap_tangent: bool, pub snap_perpendicular: bool, @@ -1221,6 +1233,7 @@ impl Default for Prefs { anchor_path_labels: true, measurement_labels: true, spacing_guides: true, + distance_guides: true, snapping_tolerance: 4.0, snap_tangent: true, snap_perpendicular: true, @@ -1364,6 +1377,8 @@ pub struct Session { pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>, /// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved. pub style_libraries: cmd::stylelib::Libraries, + /// User Defined and loaded symbol libraries (Window → Symbol Libraries); not saved. + pub symbol_libraries: cmd::symbollib::Libraries, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session @@ -1425,10 +1440,13 @@ pub struct Session { pub(crate) liquify_stroke: Option>, /// A press on the Plane Switching Widget is under way: its drag and release are the widget's. pub(crate) plane_widget_press: bool, + /// The pointer button is held: the last pointer event was a press or a drag, not a release or + /// a move ([`Session::pointer_held`]). + pub(crate) pointer_held: bool, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option, - /// Which selected type overflows its frame, for the markers every tool but the Type tools shows. - pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks, + /// Which selected type overflows its frame, for the ports every tool but the Type tools shows. + pub(crate) port_marks: vectorcraft_tools::text::PortMarks, /// The last search for the files of missing fonts (`text.findFontFiles`), until another /// starts; dropping it stops it. pub(crate) font_search: Option, @@ -1449,6 +1467,9 @@ pub struct Session { pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>, /// The stamps being taken on a worker thread ([`Session::start_link_scan`]). pub link_scan: Option, + /// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not + /// saved. + pub global_edit_options: cmd::globaledit::Options, } impl Default for Session { @@ -1465,6 +1486,12 @@ impl Session { // Placed documents are read by the native format's loader. vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again); + vectorcraft_doc::placed_document::set_read_check(|path| file_access::check_read(path).is_ok()); + // The background workers of placed documents read files in the scope that asked. + vectorcraft_render::placed_document::set_scope_capture(|| { + let scope = file_access::ScopeHandle::capture(); + Box::new(move |f: &mut dyn FnMut()| scope.run(f)) + }); // Clicks find an image's own fills and strokes where their effects draw them. vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); // OpenType SVG glyphs are drawn as the SVG importer reads their documents. @@ -1499,6 +1526,7 @@ impl Session { swatch_libraries: Default::default(), freeform_point: None, style_libraries: Default::default(), + symbol_libraries: Default::default(), libraries: Default::default(), pdf_preset_dir: None, profile_dir: None, @@ -1520,8 +1548,9 @@ impl Session { envelope_defaults: None, liquify_stroke: None, plane_widget_press: false, + pointer_held: false, ruler_guide: None, - overflow_marks: Default::default(), + port_marks: Default::default(), font_search: None, link_search: None, search_rules: None, @@ -1529,6 +1558,7 @@ impl Session { search_threads: None, link_stamps: Default::default(), link_scan: None, + global_edit_options: Default::default(), } } @@ -1713,13 +1743,25 @@ impl Session { self.note_depth = 1; self.batch_stash = None; } - // A selection-only command given `ids` selects them first, in the same undo step (#2130). + // 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)); let run = |s: &mut Session| { if let Some(ids) = select_first { s.execute("select.set", &serde_json::json!({ "ids": ids }))?; } - (spec.run)(s, params) + let r = (spec.run)(s, params); + if r.is_err() + && let Some((uid, selection, layer)) = selected_before + && let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid) + { + st.selection = selection; + st.active_layer = layer; + st.revision += 1; + } + r }; // The entries the command journals belong to the undo groups of the documents it edits, and to // the group of the document it starts in when it ends there too. @@ -1818,6 +1860,7 @@ impl Session { st.doc = doc; st.selection = selection; st.interaction = interaction; + cmd::globaledit::follow_history(st); st.revision += 1; } if active.is_some_and(|i| i < self.docs.len()) { @@ -1847,8 +1890,14 @@ impl Session { let before = st.doc.clone(); let before_sel = st.selection.clone(); let doc = Arc::make_mut(&mut st.doc); + // The Global Edit members the edit reached besides the selection. + let mut spread = vec![]; // Objects attached to a path follow it, and dimensions follow the art they measure. let result = match f(doc, &mut st.selection).and_then(|v| { + // Global Edit: the other members take what the edit changed about the one it edited. + if let Some(ge) = &st.global_edit { + spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]); + } cmd::objects_on_path::refresh(&before, doc); cmd::dimension::refresh(&before, doc)?; Ok(v) @@ -1881,7 +1930,7 @@ impl Session { if st.doc.too_deep(Some(&before)) { // Deeper trees overflow the stack of what walks them. Err(EngineError::Other(TOO_DEEP.into())) - } else if doc_sane(&st.doc, &st.selection) { + } else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) { Ok(v) } else { Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) @@ -1913,6 +1962,7 @@ impl Session { Err(e) => { st.doc = before; st.selection = before_sel; + cmd::globaledit::follow_history(st); Err(e) } } @@ -2031,6 +2081,7 @@ impl Session { if selection_changed { st.selection.clone_from(&it.selection); } + cmd::globaledit::follow_history(st); } // The previous preview's paint is undone with it. self.pending_paint = None; @@ -2187,6 +2238,7 @@ impl Session { deselect_guides(&st.doc, &mut st.selection); } st.sync_new_art(); + cmd::globaledit::follow_history(st); st.revision += 1; // The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile. journal.extend(g.journal); @@ -2264,6 +2316,7 @@ impl Session { } st.doc = doc; st.selection = selection; + cmd::globaledit::follow_history(st); if guides_locked { deselect_guides(&st.doc, &mut st.selection); } @@ -2303,6 +2356,15 @@ fn refresh_tree_text_bounds(n: &mut Arc) { } } +/// Held by the tests that add fonts to the shared [`vectorcraft_text::FontDb::global`] for as long +/// as they run: adding a font clears the database's caches, which would change how another such +/// test's type resolves between its layout and its export. +#[cfg(test)] +pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> { + static FONTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests; #[cfg(test)] @@ -2408,6 +2470,8 @@ mod tests_fontlist; #[cfg(test)] mod tests_freeform; #[cfg(test)] +mod tests_globaledit; +#[cfg(test)] mod tests_gradient; #[cfg(test)] mod tests_gradpanel; @@ -2568,6 +2632,8 @@ mod tests_shaper; #[cfg(test)] mod tests_shortcutset; #[cfg(test)] +mod tests_simplify_part; +#[cfg(test)] mod tests_slices; #[cfg(test)] mod tests_smartguides; @@ -2596,6 +2662,8 @@ mod tests_swatchlib; #[cfg(test)] mod tests_symbolbounds; #[cfg(test)] +mod tests_symbollib; +#[cfg(test)] mod tests_targeting; #[cfg(test)] mod tests_textcombos; @@ -2614,6 +2682,8 @@ mod tests_transformfx; #[cfg(test)] mod tests_transparencygrid; #[cfg(test)] +mod tests_trap; +#[cfg(test)] mod tests_typearea; #[cfg(test)] mod tests_typescale; @@ -2658,6 +2728,7 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ "object.pathfinder.minusBack", "object.pathfinder.minusFront", "object.pathfinder.outline", + "object.pathfinder.trap", "object.pathfinder.trim", "object.pathfinder.unite", "object.reflect", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed7cee619..f0c081ef6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -825,6 +825,26 @@ fn selection_tool_drag_is_one_undo_step() { assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0); } +/// The pointer button is held from a press or a drag until a release or a move. The Dimension +/// tool stays busy after its first click with the button released, waiting for the second one. +#[test] +fn the_pointer_is_held_from_a_press_until_a_release_or_a_move() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("dimension", v).unwrap(); + let mut held = vec![]; + for (kind, x) in [(PointerKind::Down, 100.0), (PointerKind::Drag, 100.0), (PointerKind::Up, 100.0)] { + s.pointer(&PointerEvent::new(kind, x, 100.0), v).unwrap(); + held.push(s.pointer_held()); + } + assert!(s.tool_busy(), "the first point waits for the second"); + for (kind, x) in [(PointerKind::Move, 200.0), (PointerKind::Down, 300.0), (PointerKind::Drag, 310.0), (PointerKind::Move, 320.0)] { + s.pointer(&PointerEvent::new(kind, x, 100.0), v).unwrap(); + held.push(s.pointer_held()); + } + assert_eq!(held, [true, true, false, false, true, true, false]); +} + /// How many steps there are to undo and to redo, and whether the document has unsaved changes. pub(crate) fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap(); diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests_adjust.rs index 9649e12b3..fc444ba95 100644 --- a/crates/engine/src/tests_adjust.rs +++ b/crates/engine/src/tests_adjust.rs @@ -42,6 +42,7 @@ fn red_image(s: &mut Session) -> NodeId { xf: vectorcraft_geom::Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index fa85a3d1d..3464c3c77 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -1976,7 +1976,15 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node 0, Node::new( id, - NodeKind::Image(Box::new(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key: "img1".into(), + width: w, + height: h, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ), )?; sel.set([id]); diff --git a/crates/engine/src/tests_colourfonts.rs b/crates/engine/src/tests_colourfonts.rs index b3926d5d9..7ea341aef 100644 --- a/crates/engine/src/tests_colourfonts.rs +++ b/crates/engine/src/tests_colourfonts.rs @@ -60,6 +60,7 @@ fn with_svg_type(family: &str, document: &str) -> Session { /// session registers. #[test] fn create_outlines_turns_svg_glyphs_into_groups() { + let _fonts = crate::adding_fonts(); let g = source_sans_gid(SVG_CHAR).unwrap(); let doc = format!( r##""## @@ -81,6 +82,7 @@ fn create_outlines_turns_svg_glyphs_into_groups() { /// draws nothing itself, and its art is drawn where it sits: no outline of the font's covers it. #[test] fn pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters() { + let _fonts = crate::adding_fonts(); let g = source_sans_gid(SVG_CHAR).unwrap(); let doc = format!(r##""##); let s = with_svg_type("Svgtest Pdf", &doc); @@ -110,6 +112,7 @@ fn pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters() { /// text draws nothing, as SVG glyphs' do). #[test] fn pdf_real_text_draws_the_outline_of_an_svg_glyph_without_art() { + let _fonts = crate::adding_fonts(); let doc = r##""##; let s = with_svg_type("Svgtest Bare Pdf", doc); let d = s.doc().unwrap().doc.clone(); @@ -122,6 +125,7 @@ fn pdf_real_text_draws_the_outline_of_an_svg_glyph_without_art() { /// In a font that may not be embedded, an SVG glyph's art is drawn once. #[test] fn pdf_draws_an_svg_glyphs_art_once_when_its_font_cant_be_embedded() { + let _fonts = crate::adding_fonts(); let g = source_sans_gid(SVG_CHAR).unwrap(); let doc = format!(r##""##); let mut s = session(); @@ -132,3 +136,131 @@ fn pdf_draws_an_svg_glyphs_art_once_when_its_font_cant_be_embedded() { let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0)); assert_eq!(fills_of(&pdf).iter().filter(|(p, _)| *p == red).count(), 1); } + +/// Expand Appearance of type with an effect of its own (drawn reshaped as its outlines) keeps its +/// colour glyphs, as their art. +#[test] +fn expand_appearance_keeps_the_colour_glyphs_of_type_with_effects() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font}; + vectorcraft_text::FontDb::global().add_font(colr_font(true).unwrap()); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F"), "size": 32, "font": colr_family(true)})).unwrap(); + let t = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"ids": [t.0], "effect": "distort.transform", "params": {"moveH": 5}})).unwrap(); + let r = ids(&s.execute("effect.expandAppearance", &json!({})).unwrap()); + let mut gradients = 0; + for id in r { + node(&s, id).walk(&mut |n| gradients += usize::from(matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::Gradient(_)))); + } + assert!(gradients > 0, "the colour glyph's gradient layers"); + vectorcraft_testkit::invariants::check_document(&s.doc().unwrap().doc).unwrap(); +} + +/// The canvas draws an SVG glyph as its art: its own colours, and the type's fill where it uses +/// `currentColor`. +#[test] +fn the_canvas_draws_svg_glyphs_as_their_art() { + let _fonts = crate::adding_fonts(); + let g = source_sans_gid(SVG_CHAR).unwrap(); + // The left half of a 500 by 700 box red, the right half in the text's colour. + let doc = format!( + r##""## + ); + let s = with_svg_type("Svgtest Canvas", &doc); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, vectorcraft_geom::Affine::IDENTITY, &opts); + // At 32 pt from x 10 on the baseline at y 50: 16 pt wide, 22.4 pt tall. + assert_eq!(img.pixel(14, 40), [255, 0, 0, 255], "its own red"); + assert_eq!(img.pixel(22, 40), [0, 0, 0, 255], "the type's black fill"); +} + +/// Expand Appearance of type with an effect keeps its emoji as images, their pixels taken into +/// the document; the PDF of the type before expanding draws them too. +#[test] +fn expand_appearance_keeps_the_emoji_of_type_with_effects() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font, solid_png}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut s = session(); + let r = s + .execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F"), "size": 32, "font": colour_bitmap_family(false)})) + .unwrap(); + let t = NodeId(r["id"].as_u64().unwrap()); + // Turned, not only moved: type that a Transform only moves stays type in exports. + s.execute("effect.apply", &json!({"ids": [t.0], "effect": "distort.transform", "params": {"rotate": 10}})).unwrap(); + let pdf = vectorcraft_pdf::export_with_report(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + assert!(pdf.warnings.is_empty(), "{:?}", pdf.warnings); + assert!(pdf.bytes.windows(14).any(|w| w == b"/Subtype/Image"), "the emoji, in the PDF"); + let r = ids(&s.execute("effect.expandAppearance", &json!({})).unwrap()); + let mut images = vec![]; + for id in r { + node(&s, id).walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + images.push((**im).clone()); + } + }); + } + let d = &s.doc().unwrap().doc; + let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE)); + assert_eq!(images.len(), 1, "the emoji"); + assert!(images[0].pixels.is_none(), "the document holds them"); + assert_eq!(d.images[&images[0].key].bytes, glyph.bytes); + vectorcraft_testkit::invariants::check_document(d).unwrap(); +} + +/// Emoji type in a warp envelope: SVG export writes each emoji's pixels once for all the warp's +/// pieces, and Object › Expand takes them into the document. +#[test] +fn an_envelope_shares_and_expands_the_emoji_of_its_type() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut s = session(); + let r = s + .execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)})) + .unwrap(); + s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap(); + let e = NodeId(s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap()["id"].as_u64().unwrap()); + let svg = vectorcraft_svg::export_full(&s.doc().unwrap().doc, &Default::default(), None).svg; + assert_eq!(svg.matches("data:image/png").count(), 1, "one copy of the pixels"); + s.execute("object.envelope.expand", &json!({})).unwrap(); + let mut images = vec![]; + node(&s, e).walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + images.push((**im).clone()); + } + }); + let d = &s.doc().unwrap().doc; + assert!(images.len() > 1, "the warp's pieces"); + assert!(images.iter().all(|im| im.pixels.is_none() && d.images.contains_key(&im.key)), "the document holds them"); + vectorcraft_testkit::invariants::check_document(d).unwrap(); +} + +/// An image in an SVG glyph's document draws on the canvas, and Create Outlines takes its pixels +/// into the document. +#[test] +fn svg_glyph_images_draw_and_outline() { + let g = source_sans_gid(SVG_CHAR).unwrap(); + let png = vectorcraft_text::test_fonts::solid_png(4, [0, 0, 255, 255]); + let doc = format!( + r##""##, + vectorcraft_svg::base64_encode(&png) + ); + let mut s = with_svg_type("Svgtest Images", &doc); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, vectorcraft_geom::Affine::IDENTITY, &opts); + // At 32 pt from x 10 on the baseline at y 50: 16 pt wide, 22.4 pt tall. + assert_eq!(img.pixel(18, 40), [0, 0, 255, 255], "the glyph's image"); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let mut images = vec![]; + node(&s, r[0]).walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + images.push((**im).clone()); + } + }); + let d = &s.doc().unwrap().doc; + assert_eq!(images.len(), 1); + assert!(images[0].pixels.is_none() && d.images[&images[0].key].bytes.as_slice() == png.as_slice(), "the document holds its pixels"); + vectorcraft_testkit::invariants::check_document(d).unwrap(); +} diff --git a/crates/engine/src/tests_constrain_tools.rs b/crates/engine/src/tests_constrain_tools.rs index 5a9c2ddbd..ca6999c79 100644 --- a/crates/engine/src/tests_constrain_tools.rs +++ b/crates/engine/src/tests_constrain_tools.rs @@ -152,3 +152,28 @@ fn constrain_angle_whole_turns_nudge_as_zero() { set_pref(&mut s, "constrainAngle", json!(90)); assert_eq!(moved(&mut s, 1, 0), Vec2::new(0.0, -10.0)); } + +/// General › Constrain Angle turns only the art's arrow-key axes: ruler guides selected with the +/// art keep to the page's axes. At 90°, → moves the art straight up, a vertical guide 10 right and +/// a horizontal guide not at all; ↓ moves the horizontal guide 10 down. Copies alike. +#[test] +fn constrain_angle_keeps_guides_selected_with_art_on_the_page_axes() { + let mut s = new_doc(); + let id = rect(&mut s, 100.0, 100.0); + set_pref(&mut s, "keyboardIncrement", json!(10)); + set_pref(&mut s, "constrainAngle", json!(90)); + let v = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; + let h = s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [v, h], "toggle": true})).unwrap(); + let pos = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let at = centre(&s, id); + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(centre(&s, id) - at, Vec2::new(0.0, -10.0), "the art moves along the turned axis"); + assert_eq!(pos(&s), [60.0, 50.0], "→: the vertical guide moves right, the horizontal one stays"); + s.execute("object.nudge", &json!({"dx": 0, "dy": 1})).unwrap(); + assert_eq!(pos(&s), [60.0, 60.0], "↓: the horizontal guide moves down"); + // With a copy, the guides' copies keep to the page too. + s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "copy": true})).unwrap(); + assert_eq!(pos(&s), [60.0, 60.0, 70.0, 60.0]); +} diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index d5278c566..3429829d7 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -116,6 +116,31 @@ fn pencil_alt_closes_path() { assert!(ps[0].1.is_closed()); } +/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the +/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°). +#[test] +fn pencil_with_shift_draws_a_straight_line() { + let mut s = session(); + s.execute("prefs.set", &json!({"key": "constrainAngle", "value": 30})).unwrap(); + let v = view(); + let shift = Mods { shift: true, ..Default::default() }; + s.select_tool("pencil", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0).with_mods(shift), v).unwrap(); + for (x, y) in [(120.0, 380.0), (125.0, 330.0), (160.0, 320.0), (150.0, 313.4)] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(shift), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 313.4).with_mods(shift), v).unwrap(); + let ps = paths(&s); + assert_eq!(ps.len(), 1); + let sp = &ps[0].1.subpaths[0]; + assert_eq!(sp.anchors.len(), 2, "{sp:?}"); + assert!(sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p), "no handles: {sp:?}"); + let (a, b) = (sp.anchors[0].p, sp.anchors[1].p); + assert!(a.distance(Point::new(100.0, 400.0)) < 1e-6, "{a:?}"); + let angle = (-(b.y - a.y)).atan2(b.x - a.x).to_degrees(); + assert!((angle - 75.0).abs() < 1e-6, "{angle}"); +} + #[test] fn pencil_continues_selected_open_path() { let mut s = session(); diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index 0dd51c98e..ee4c050a8 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -178,6 +178,7 @@ fn image_node(s: &mut Session) -> NodeId { xf: Default::default(), link: None, placement: Default::default(), + pixels: None, }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests_envelope_distort.rs index b02fdb121..00b2bf6e9 100644 --- a/crates/engine/src/tests_envelope_distort.rs +++ b/crates/engine/src/tests_envelope_distort.rs @@ -122,7 +122,7 @@ fn red_image(s: &mut Session) -> NodeId { d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); let id = d.alloc_id(); let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); - let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() }; + let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default(), pixels: None }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index 5fd492a40..4ef5773ca 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -160,7 +160,7 @@ fn clicking_an_image_samples_its_pixel() { let l = d.layers[0].id; let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); - let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); @@ -186,7 +186,7 @@ fn cmyk_doc_with_image(px: &[[u8; 4]]) -> Session { let l = d.layers[0].id; let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); - let im = ImageObject { key: "px".into(), width: w, height: 1, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: "px".into(), width: w, height: 1, xf, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index 7c9fc14c6..01837b0c9 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -242,6 +242,64 @@ fn pasting_in_draw_inside_puts_the_copies_inside_the_object() { assert_eq!((s.doc().unwrap().doc.node(g).unwrap().kind_label(), parent(&s, ids[0])), ("Clip Group", Some(g))); } +/// #3705: in Draw Inside, with the object isolated by a double-click with the Selection tool, what +/// is drawn or pasted inside it stays in isolation mode: isolation moves to the clip group Draw +/// Inside makes around the object, so a click picks the pasted copy and it draws undimmed. It used +/// to stay on the bare path, the new art locked and dimmed beside it, and double-clicks never +/// reached the clip group. Undoing the step that made the clip group leaves isolation mode, as the +/// group is gone. +#[test] +fn drawing_inside_an_isolated_object_keeps_the_new_art_in_isolation_mode() { + use vectorcraft_geom::Affine; + use vectorcraft_render::{RenderOptions, Renderer}; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let red = s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [red]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| { + for k in kinds { + s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap(); + } + }; + s.select_tool("selection", ViewInfo::default()).unwrap(); + ev(&mut s, &[PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick], 150.0, 150.0); + assert_eq!(s.doc().unwrap().isolation, Some(NodeId(a)), "the double-click isolates the object"); + // An ellipse dragged out with the Ellipse tool (several previews) goes inside the object, and + // isolation mode with it. + s.select_tool("ellipse", ViewInfo::default()).unwrap(); + ev(&mut s, &[PointerKind::Down], 110.0, 110.0); + for d in [5.0, 10.0, 15.0, 20.0] { + ev(&mut s, &[PointerKind::Drag], 110.0 + d, 110.0 + d); + } + ev(&mut s, &[PointerKind::Up], 130.0, 130.0); + let d = &s.doc().unwrap().doc; + let g = d.parent_of(NodeId(a)).unwrap(); + assert_eq!(d.node(g).unwrap().kind_label(), "Clip Group"); + assert_eq!(s.doc().unwrap().isolation, Some(g), "isolation mode follows the object into its clip group"); + // A paste goes into it too, in isolation mode, and a click with the Selection tool picks it. + let pasted = s.execute("edit.paste", &json!({"center": [170, 170]})).unwrap()["ids"][0].as_u64().unwrap(); + let st = s.doc().unwrap(); + assert_eq!((st.doc.parent_of(NodeId(pasted)), st.isolation), (Some(g), Some(g))); + // Isolation mode draws the art around what is isolated dimmed: the red copy is not. + let opts = RenderOptions { background: Some([255, 255, 255, 255]), isolated: st.isolation, ..Default::default() }; + let p = Renderer::new().render(&st.doc, 400, 300, Affine::IDENTITY, &opts).pixel(170, 170); + assert_eq!([p[0], p[1], p[2]], [255, 0, 0], "the pasted copy draws undimmed"); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("selection", ViewInfo::default()).unwrap(); + ev(&mut s, &[PointerKind::Down, PointerKind::Up], 170.0, 170.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(pasted)], "a click picks the pasted copy"); + // Undoing the paste keeps isolation mode; undoing the ellipse, which made the clip group, ends it. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(g)); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.node(g).is_none() && st.isolation.is_none(), "{:?}", st.isolation); +} + /// In Draw Behind, Paste in Place and Paste on All Artboards put the copies just below the /// selection, as Paste in Back does; Paste puts them on top, as in Draw Normal. #[test] diff --git a/crates/engine/src/tests_globaledit.rs b/crates/engine/src/tests_globaledit.rs new file mode 100644 index 000000000..c928d1712 --- /dev/null +++ b/crates/engine/src/tests_globaledit.rs @@ -0,0 +1,445 @@ +//! Select › Start Global Edit (`select.globalEdit`, `select.globalEditOptions`): which objects +//! count as similar, and edits made to one of them reaching the others in proportion, as one undo +//! step. Behaviour: . + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, PathData, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + node(s, id).geometric_bounds().unwrap() +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +fn start(s: &mut Session, key: NodeId, p: Value) -> Vec { + sel(s, &[key]); + let r = s.execute("select.globalEdit", &p).unwrap(); + assert_eq!(r["active"], true); + ids(&r) +} + +fn fill(s: &Session, id: NodeId) -> String { + node(s, id).appearance.fill_paint().color().unwrap().to_hex() +} + +fn stroke(s: &Session, id: NodeId) -> (String, f64) { + let n = node(s, id); + (n.appearance.stroke_paint().color().unwrap().to_hex(), n.appearance.stroke_width()) +} + +fn members(s: &Session) -> Option> { + s.doc().unwrap().global_edit.as_ref().map(|g| g.members.iter().map(|m| m.0).collect()) +} + +fn close(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6) +} + +/// `b`'s outline is `a`'s placed by `xf`, anchor for anchor. +fn placed(s: &Session, a: NodeId, b: NodeId, xf: Affine) -> bool { + let (pa, pb): (PathData, PathData) = (node(s, a).path_data().unwrap().transformed(xf), node(s, b).path_data().unwrap().clone()); + pa.subpaths.len() == pb.subpaths.len() + && pa.subpaths.iter().zip(&pb.subpaths).all(|(x, y)| { + x.closed == y.closed + && x.anchors.len() == y.anchors.len() + && x.anchors + .iter() + .zip(&y.anchors) + .all(|(m, n)| m.p.distance(n.p) < 1e-6 && m.h_in.distance(n.h_in) < 1e-6 && m.h_out.distance(n.h_out) < 1e-6) + }) +} + +/// A 100 pt square, a copy of it, a 20 pt square (the same shape, smaller), a 100 × 50 rectangle +/// and a 100 pt circle (other shapes). +fn squares(s: &mut Session) -> [NodeId; 5] { + [rect(s, 0.0, 0.0, 100.0, 100.0), rect(s, 300.0, 0.0, 100.0, 100.0), rect(s, 500.0, 0.0, 20.0, 20.0), rect(s, 0.0, 300.0, 100.0, 50.0), { + let r = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + }] +} + +#[test] +fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() { + let mut s = session(); + let [a, b, c, wide, circle] = squares(&mut s); + let members = start(&mut s, a, json!({})); + assert_eq!(members, [a, b, c], "the copy and the smaller square; not the rectangle {wide} nor the circle {circle}"); + // The selected object stays the selection; the others are highlighted on the canvas. + assert_eq!(s.doc().unwrap().selection.objects, [a]); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(r["active"], false, "the second run is Stop Global Edit"); + assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty()); +} + +#[test] +fn size_and_appearance_options_narrow_the_match() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "the smaller square has another size"); + s.execute("select.globalEdit", &json!({})).unwrap(); + sel(&mut s, &[b]); + s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); + assert_eq!(start(&mut s, a, json!({"appearance": true})), [a, c], "the copy has another fill now"); + s.execute("select.globalEdit", &json!({})).unwrap(); + // The options are kept for the session, and report how many objects they take. + sel(&mut s, &[a]); + let r = s.execute("select.globalEditOptions", &json!({"size": true, "appearance": true})).unwrap(); + assert_eq!((r["size"].clone(), r["appearance"].clone(), r["count"].clone()), (json!(true), json!(true), json!(1))); + let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no similar objects"), "{e}"); + s.execute("select.globalEditOptions", &json!({"size": false, "appearance": false})).unwrap(); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); +} + +#[test] +fn an_edit_reaches_every_member_in_proportion_as_one_undo_step() { + let mut s = session(); + let [a, b, c, wide, circle] = squares(&mut s); + start(&mut s, a, json!({})); + let before = (*s.doc().unwrap().doc).clone(); + let steps = s.doc().unwrap().history.undo.len(); + let (wide_was, circle_was) = (node(&s, wide), node(&s, circle)); + // Height 100 → 50 from the top left (the Transform panel): the 20 pt square goes to 10 pt. + s.execute("object.setBounds", &json!({"height": 50, "reference": 0})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 50.0)), "{:?}", bounds(&s, a)); + assert!(close(bounds(&s, b), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, b)); + assert!(close(bounds(&s, c), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, c)); + assert_eq!((node(&s, wide), node(&s, circle)), (wide_was, circle_was), "objects of other shapes are left alone"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + crate::tests_large_edits::undo_gives_back(&mut s, &before); +} + +#[test] +fn an_anchor_moved_on_one_member_moves_on_the_others() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + start(&mut s, a, json!({})); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap(); + assert!(placed(&s, a, b, Affine::translate((300.0, 0.0)))); + assert!(placed(&s, a, c, Affine::translate((500.0, 0.0)) * Affine::scale(0.2))); + assert!(!placed(&s, a, c, Affine::translate((500.0, 0.0))), "the smaller square's anchor moves a fifth as far"); +} + +#[test] +fn a_drag_moves_the_members_with_each_preview_and_commits_one_step() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + start(&mut s, a, json!({})); + let before = (*s.doc().unwrap().doc).clone(); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); + s.preview("object.move", &json!({"dx": 40, "dy": 0})).unwrap(); + assert!(close(bounds(&s, b), Rect::new(340.0, 0.0, 440.0, 100.0)), "the second preview replaces the first: {:?}", bounds(&s, b)); + s.commit_interaction().unwrap(); + assert!(close(bounds(&s, c), Rect::new(508.0, 0.0, 528.0, 20.0)), "{:?}", bounds(&s, c)); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move"); + crate::tests_large_edits::undo_gives_back(&mut s, &before); +} + +#[test] +fn any_selected_member_leads_and_fills_reach_the_others_only_when_they_change() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[c]); + s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap(); + start(&mut s, a, json!({})); + let c_fill = fill(&s, c); + // Moving the copy (selected during Global Edit) moves the others in proportion and keeps + // their own fills. + sel(&mut s, &[b]); + s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)), "{:?}", bounds(&s, a)); + assert!(close(bounds(&s, c), Rect::new(510.0, 0.0, 530.0, 20.0)), "{:?}", bounds(&s, c)); + assert_eq!(fill(&s, c), c_fill); + // A new fill reaches all of them, and the stroke weight stays the model's, not scaled. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + for id in [a, b, c] { + assert_eq!(fill(&s, id), "#ff0000"); + assert_eq!(node(&s, id).appearance.stroke_width(), 4.0); + } + // Selection changes alone edit nothing; after Stop Global Edit an edit stays on its object. + s.execute("select.globalEdit", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0))); +} + +#[test] +fn locked_and_hidden_objects_are_left_out() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + let locked = rect(&mut s, 0.0, 450.0, 100.0, 100.0); + let hidden = rect(&mut s, 150.0, 450.0, 100.0, 100.0); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap(); + assert_eq!(start(&mut s, a, json!({})), [a, b, c], "locked and hidden squares are not found"); + // A member locked after the start is not edited either. + s.execute("object.lock", &json!({"ids": [b.0]})).unwrap(); + let b_was = node(&s, b); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap(); + assert_eq!(node(&s, b), b_was); + assert!(close(bounds(&s, c), Rect::new(500.0, 2.0, 520.0, 22.0)), "{:?}", bounds(&s, c)); + assert_eq!(bounds(&s, locked), Rect::new(0.0, 450.0, 100.0, 550.0)); + assert_eq!(bounds(&s, hidden), Rect::new(150.0, 450.0, 250.0, 550.0)); +} + +#[test] +fn artboard_options_limit_where_similar_objects_are_found() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap(); + let key = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + let same_board = rect(&mut s, 200.0, 10.0, 50.0, 50.0); + let second = rect(&mut s, 1100.0, 10.0, 50.0, 50.0); + let canvas = rect(&mut s, 2000.0, 2000.0, 50.0, 50.0); + assert_eq!(start(&mut s, key, json!({})), [key, same_board, second, canvas], "all artboards and the canvas by default"); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(start(&mut s, key, json!({"includeCanvas": false})), [key, same_board, second]); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(start(&mut s, key, json!({"artboards": "2"})), [key, second, canvas]); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(start(&mut s, key, json!({"artboards": "2", "includeCanvas": false})), [key, second]); + s.execute("select.globalEdit", &json!({})).unwrap(); + let e = s.execute("select.globalEdit", &json!({"artboards": "3"})).unwrap_err().to_string(); + assert!(e.contains("1 to 2"), "{e}"); +} + +#[test] +fn compound_paths_match_whole_and_keep_their_own_member_ids() { + let mut s = session(); + let compound = |s: &mut Session, x: f64, k: f64| { + let (p, q) = (rect(s, x, 0.0, 100.0 * k, 100.0 * k), rect(s, x + 25.0 * k, 25.0 * k, 50.0 * k, 50.0 * k)); + sel(s, &[p, q]); + NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()) + }; + let (one, two) = (compound(&mut s, 0.0, 1.0), compound(&mut s, 300.0, 0.5)); + let lone = rect(&mut s, 600.0, 0.0, 100.0, 100.0); + assert_eq!(start(&mut s, one, json!({})), [one, two], "not the square {lone} alone"); + let kids = |s: &Session, id: NodeId| node(s, id).children().unwrap().iter().map(|c| c.id).collect::>(); + let two_kids = kids(&s, two); + s.execute("object.setBounds", &json!({"width": 200, "reference": 0})).unwrap(); + assert!(close(bounds(&s, two), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, two)); + assert_eq!(kids(&s, two), two_kids); + assert!(matches!(node(&s, two).kind, NodeKind::Compound { .. })); +} + +#[test] +fn symbol_instances_of_one_symbol_match() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let made = s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap(); + let first = NodeId(made["id"].as_u64().unwrap()); + let placed = s.execute("symbol.place", &json!({"name": "Tile", "x": 300, "y": 300})).unwrap(); + let second = NodeId(placed["id"].as_u64().unwrap()); + let other = rect(&mut s, 500.0, 500.0, 40.0, 40.0); + assert_eq!(start(&mut s, first, json!({})), [first, second], "not the plain square {other}"); + let xf = |s: &Session, id: NodeId| match node(s, id).kind { + NodeKind::SymbolInstance { xf, .. } => xf, + _ => panic!("not an instance"), + }; + let was = xf(&s, second); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + let (now, want) = (xf(&s, second).as_coeffs(), (Affine::translate((10.0, 0.0)) * was).as_coeffs()); + assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}"); +} + +#[test] +fn global_edit_needs_one_supported_object() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + sel(&mut s, &[a, b]); + assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "a selection of two"); + let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + sel(&mut s, &[t]); + assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type"); + let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0); + // A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the + // message says so. + let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0); + sel(&mut s, &[turned]); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + sel(&mut s, &[lone]); + let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}"); + assert!(s.doc().unwrap().global_edit.is_none()); + // A group of two squares is refused, though another such group exists. + let group = |s: &mut Session, x: f64| { + let (p, q) = (rect(s, x, 100.0, 10.0, 10.0), rect(s, x + 20.0, 100.0, 10.0, 10.0)); + sel(s, &[p, q]); + NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()) + }; + let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0)); + sel(&mut s, &[g]); + let e = s.execute("select.globalEdit", &json!({})).unwrap_err(); + assert!(matches!(e, EngineError::Disabled(..)) && e.to_string().contains("paths, compound paths and symbol instances"), "{e}"); +} + +#[test] +fn global_edit_is_not_saved() { + let mut s = session(); + let [a, ..] = squares(&mut s); + start(&mut s, a, json!({})); + let saved = s.execute("document.serialize", &json!({})).unwrap().to_string(); + assert!(!saved.to_ascii_lowercase().contains("globaledit")); +} + +#[test] +fn a_stroke_weight_change_leaves_each_members_own_fill_and_stroke_colour() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[b]); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + start(&mut s, a, json!({})); + let (a_fill, c_fill) = (fill(&s, a), fill(&s, c)); + s.execute("stroke.set", &json!({"weight": 5})).unwrap(); + assert_eq!(fill(&s, b), "#0000ff", "the copy keeps its own fill"); + assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 5.0), "and its own stroke colour, with the new weight"); + assert_eq!(stroke(&s, c).1, 5.0, "the weight is the model's, not scaled"); + assert_eq!((fill(&s, a), fill(&s, c)), (a_fill, c_fill)); +} + +#[test] +fn a_fill_colour_change_reaches_the_fills_and_not_the_strokes() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[b]); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + start(&mut s, a, json!({})); + let (a_stroke, c_stroke) = (stroke(&s, a), stroke(&s, c)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + for id in [a, b, c] { + assert_eq!(fill(&s, id), "#ff0000"); + } + assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 3.0), "the copy keeps its own stroke"); + assert_eq!((stroke(&s, a), stroke(&s, c)), (a_stroke, c_stroke)); +} + +#[test] +fn a_fill_or_stroke_added_or_removed_reaches_the_members_that_keep_their_others() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[b]); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + // The small square has two fills: not the model's fills and strokes in order. + sel(&mut s, &[c]); + s.execute("appearance.addFill", &json!({})).unwrap(); + start(&mut s, a, json!({})); + let kinds = |s: &Session, id| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::>(); + s.execute("appearance.addStroke", &json!({})).unwrap(); + assert_eq!(kinds(&s, a), ["fill", "stroke", "stroke"]); + assert_eq!(kinds(&s, b), kinds(&s, a), "the copy gains the stroke"); + assert_eq!(fill(&s, b), "#0000ff", "and keeps its own fill"); + assert_eq!(kinds(&s, c), kinds(&s, a), "a member with other fills and strokes takes the model's list"); + s.execute("appearance.removeItem", &json!({"index": 0})).unwrap(); + assert_eq!(kinds(&s, a), ["stroke", "stroke"]); + assert_eq!(kinds(&s, b), kinds(&s, a), "the copy loses its fill"); +} + +#[test] +fn undo_and_redo_drop_the_members_the_document_lacks() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); + // Undoing the making of the small square takes it out of the Global Edit, so a square made + // under its freed id is not edited with the others. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(members(&s), Some(vec![a, b])); + let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(d, c, "the id is used again"); + let d_was = node(&s, d); + sel(&mut s, &[a]); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left"); + assert_eq!(node(&s, d), d_was); + // Redoing the deletion of a member takes it out too; with one left, Global Edit ends. + sel(&mut s, &[b]); + s.execute("edit.clear", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(members(&s), Some(vec![a, b]), "every member is there again"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(members(&s), None); +} + +/// Two 100 pt squares and a 20 pt one made after `begin`, all three in a Global Edit; `put_back` +/// takes the document back to before the small one. The small square leaves the Global Edit, and +/// a square made under its freed id is not edited with the others. +fn put_back_drops_the_member(begin: impl FnOnce(&mut Session) -> T, put_back: impl FnOnce(&mut Session, T)) { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + let t = begin(&mut s); + let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); + put_back(&mut s, t); + assert_eq!(members(&s), Some(vec![a, b])); + let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(d, c, "the id is used again"); + let d_was = node(&s, d); + sel(&mut s, &[a]); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left"); + assert_eq!(node(&s, d), d_was); +} + +#[test] +fn a_cancelled_drag_scrub_or_undo_scope_drops_the_members_it_takes_away() { + put_back_drops_the_member(|s| s.begin_interaction("Draw").unwrap(), |s, ()| s.cancel_interaction().unwrap()); + put_back_drops_the_member(Session::begin_undo_group, |s, ()| s.end_undo_group(true)); + put_back_drops_the_member( + |s| { + s.begin_undo_group(); + s.begin_undo_scope().unwrap() + }, + |s, scope| { + s.end_undo_scope(scope, true); + s.end_undo_group(false); + }, + ); +} + +#[test] +fn an_edit_that_takes_a_member_past_the_canvas_is_refused_whole() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 2.0, 2.0); + let big = rect(&mut s, 0.0, 0.0, 2000.0, 2000.0); + assert_eq!(start(&mut s, a, json!({})), [a, big]); + let before = (node(&s, a), node(&s, big)); + let steps = s.doc().unwrap().history.undo.len(); + // 4000 pt keeps the small square in range; the square 1000 times its size moves 1000 times as + // far, past `MAX_COORD`. + let e = s.execute("object.move", &json!({"dx": 4000, "dy": 0})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + assert_eq!((node(&s, a), node(&s, big)), before, "nothing moved"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + s.execute("object.move", &json!({"dx": 3000, "dy": 0})).unwrap(); + assert!(close(bounds(&s, big), Rect::new(3.0e6, 0.0, 3.0e6 + 2000.0, 2000.0)), "{:?}", bounds(&s, big)); +} diff --git a/crates/engine/src/tests_inline.rs b/crates/engine/src/tests_inline.rs index 71affad36..6ed8533b7 100644 --- a/crates/engine/src/tests_inline.rs +++ b/crates/engine/src/tests_inline.rs @@ -167,6 +167,23 @@ fn inline_graphics_follow_their_symbol() { let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); } +/// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and +/// live SVG text writes the bullet. +#[test] +fn a_list_paragraph_starting_with_an_inline_graphic_keeps_its_bullet() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let id = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Dot", "scale": 1})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet"})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert_eq!(lay.markers.len(), 1); + let bullet = &lay.glyphs[lay.markers[0].glyphs.start]; + assert!(bullet.origin.x < lay.inlines[0].bounds.x0, "the bullet comes before the graphic"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(svg.contains('\u{2022}'), "{svg}"); +} + #[test] fn saved_documents_keep_inline_graphics() { let mut s = session(); diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index fb7a23019..381ea16ae 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -499,3 +499,147 @@ fn a_range_saves_those_artboards_and_save_writes_them_again() { let r = s.execute("document.save", &json!({})).unwrap(); assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); } + +/// A 32×32 PNG checkerboard of 16-pixel squares: `a` top left and bottom right, `b` elsewhere. +fn checker(a: [u8; 4], b: [u8; 4]) -> Vec { + let img = image::RgbaImage::from_fn(32, 32, |x, y| image::Rgba(if (x < 16) == (y < 16) { a } else { b })); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A blue square (40–168) under a square at `opacity` filled with a pattern whose 32 pt tile is the +/// image `tile`, grouped. +fn pattern_over_blue(tile: &[u8], opacity: f32) -> Session { + let href = format!("data:image/png;base64,{}", vectorcraft_format::base64_encode(tile)); + let svg = format!( + r##""## + ); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "p.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + s +} + +/// #3666: a pattern fill whose tile is an image, under opacity, is flattened with the art under it +/// like a transparent image, instead of being written opaque over white. +#[test] +fn a_transparent_image_pattern_is_flattened_with_the_art_under_it() { + for (tile, what) in + [(checker([0, 200, 0, 255], [255, 230, 0, 255]), "opaque tile"), (checker([0, 200, 0, 255], [255, 255, 255, 0]), "see-through tile")] + { + let mut s = pattern_over_blue(&tile, 0.6); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{what}: {:?}", warnings(&r)); + let r = s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + assert!(!r.to_string().contains("left out"), "{what}: {:?}", warnings(&r)); + let d = &s.doc().unwrap().doc; + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(d, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{what}: before {before:?} after {after:?}"); + } + } +} + +/// #3666: an opaque pattern fill whose tile is an opaque image is still written as its tiles by the +/// writer, not flattened. +#[test] +fn an_opaque_image_pattern_is_written_as_its_tiles() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 230, 0, 255]), 1.0); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("flattened with the art under them")), "{w:?}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}"); + } +} + +/// A square (40–168) with two pattern fills of 32 pt image tiles: a red and blue checkerboard, +/// with a green checkerboard with see-through squares over it. +fn two_pattern_fills() -> Session { + let see_through = vectorcraft_format::base64_encode(&checker([0, 200, 0, 255], [255, 255, 255, 0])); + let opaque = vectorcraft_format::base64_encode(&checker([200, 0, 0, 255], [0, 0, 200, 255])); + let svg = format!( + r##""## + ); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "p.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + // Move the opaque pattern fill under the see-through one, on the second rectangle. + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let ids: Vec = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + let under = d.node(ids[0]).unwrap().appearance.items.clone(); + assert!(!under.is_empty()); + let n = d.node_mut(ids[1]).unwrap(); + let mut items = under; + items.append(&mut n.appearance.items); + n.appearance.items = items; + let _ = d.remove(ids[0]); + s +} + +/// #3666: on a shape with two pattern fills, only the one that paints a transparent image is +/// flattened; the opaque image pattern under it is still written as its tiles. +#[test] +fn only_the_transparent_image_pattern_fill_of_a_shape_is_flattened() { + let mut s = two_pattern_fills(); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + assert_eq!(before[1], [0, 0, 200], "{before:?}"); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("flattened with the art under them")), "{w:?}"); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}"); + } +} + +/// #3666: with `omit`, a pattern fill whose tile image is see-through is not expanded, so its tiles +/// aren't left out: the writer still draws them. +#[test] +fn omit_keeps_a_transparent_image_pattern() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 255, 255, 0]), 1.0); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("are left out")), "{w:?}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + assert!(images(&s.doc().unwrap().doc).0 > 0); +} + +/// #3666: expanding the transparent image patterns of a shape with two pattern fills expands only +/// the fill that paints a transparent image; the opaque image pattern stays a pattern fill. +#[test] +fn only_the_transparent_pattern_fill_of_a_leaf_is_expanded() { + let mut s = two_pattern_fills(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let keys = crate::cmd::flatten::see_through_images(d); + let layer = d.layers[0].id; + assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys).unwrap(), 1); + let mut patterns = vec![]; + let mut images = 0; + d.walk(|n| { + for i in &n.appearance.items { + if let vectorcraft_doc::appearance::AppearanceItem::Fill(f) = i + && let vectorcraft_color::Paint::Pattern { pattern, .. } = &f.paint + { + patterns.push(pattern.clone()); + } + } + images += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + }); + assert_eq!(patterns.len(), 1, "{patterns:?}"); + assert!(images > 0); +} diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index 9ddfa33e9..95ab45349 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -462,6 +462,7 @@ fn relative_paths_are_written_against_the_saved_file() { xf: Default::default(), link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))), placement: Default::default(), + pixels: None, }; d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index 084153acc..146148dc9 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -1103,7 +1103,7 @@ fn two_colour_image(s: &mut Session, clear_right: bool, linked: bool) -> NodeId let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((100.0, 50.0)) * vectorcraft_geom::Affine::scale(2.0); let link = linked.then(|| vectorcraft_doc::LinkInfo { path: "/tmp/pic.png".into(), ..Default::default() }); - let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default() }; + let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default(), pixels: None }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) diff --git a/crates/engine/src/tests_newart.rs b/crates/engine/src/tests_newart.rs index 5be2e2069..cc1ced4f7 100644 --- a/crates/engine/src/tests_newart.rs +++ b/crates/engine/src/tests_newart.rs @@ -141,3 +141,17 @@ fn the_option_round_trips_with_the_preferences() { let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap(); assert!(old.new_art_basic); } + +/// Line Segment Tool Options' Fill Line: `shape.line {fill: true}` gives the line the current +/// fill; without it a new line has none. +#[test] +fn fill_line_gives_a_new_line_the_current_fill() { + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let plain = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 200, "y2": 80}))["id"].as_u64().unwrap()); + let filled = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 200, "y2": 80, "fill": true}))["id"].as_u64().unwrap()); + assert_eq!(node(&s, plain).appearance.fill_paint(), Paint::None); + assert_eq!(node(&s, filled).appearance.fill_paint(), s.paint.fill); + assert_ne!(s.paint.fill, Paint::None); + assert!(s.execute("shape.line", &json!({"x1": 0, "y1": 0, "x2": 1, "y2": 1, "fill": "yes"})).is_err()); +} diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs index bada2ebba..a2f840c67 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests_newdoc.rs @@ -174,6 +174,20 @@ fn categories_list_generated_presets_with_neutral_names() { assert!(s.execute("file.newPresets", &json!({"category": "Furniture"})).is_err()); } +/// Branding has the Japanese business card, 91 × 55 mm, after the 85 × 55 mm one (#3828). +#[test] +fn branding_has_the_japanese_business_card() { + let mut s = Session::new(); + let branding = presets(&mut s, "Branding"); + let names: Vec<&str> = branding.iter().map(|p| p["name"].as_str().unwrap()).collect(); + let at = names.iter().position(|n| *n == "Business Card 91 × 55 mm").unwrap_or_else(|| panic!("{names:?}")); + assert_eq!(names[at - 1], "Business Card 85 × 55 mm"); + s.execute("file.new", &json!({"preset": "Business Card 91 × 55 mm"})).unwrap(); + let b = boards(&s)[0]; + let mm = 72.0 / 25.4; + assert!((b.width() - 91.0 * mm).abs() < 1e-6 && (b.height() - 55.0 * mm).abs() < 1e-6, "{b:?}"); +} + #[test] fn recent_remembers_the_last_sizes() { let mut s = Session::new(); diff --git a/crates/engine/src/tests_outlineobject.rs b/crates/engine/src/tests_outlineobject.rs index f11cb88cf..c1ff06b53 100644 --- a/crates/engine/src/tests_outlineobject.rs +++ b/crates/engine/src/tests_outlineobject.rs @@ -56,7 +56,7 @@ fn type_with_outline_object_stays_editable_type_and_keeps_its_geometric_bounds() assert_eq!(s.transform_bounds(&[id]), before); // With Use Preview Bounds on, the type is measured by its glyph outlines. s.prefs.use_preview_bounds = true; - let glyphs = vectorcraft_render::effects::reshape(&node(&s, id), None).unwrap().geometric_bounds(); + let glyphs = vectorcraft_render::effects::reshape(None, &node(&s, id), None).unwrap().geometric_bounds(); assert_eq!(s.transform_bounds(&[id]), glyphs); s.prefs.use_preview_bounds = false; // A native save keeps the effect. @@ -66,10 +66,10 @@ fn type_with_outline_object_stays_editable_type_and_keeps_its_geometric_bounds() // The type is still type, drawn as its outlines, and a text edit changes them. let n = node(&s, id); assert!(matches!(n.kind, NodeKind::Text(_))); - let drawn = vectorcraft_render::effects::reshape(&n, None).unwrap(); + let drawn = vectorcraft_render::effects::reshape(None, &n, None).unwrap(); assert_eq!(count_text(&drawn), 0); s.execute("text.setText", &json!({"id": id.0, "text": "OUTLINED TYPE"})).unwrap(); - let longer = vectorcraft_render::effects::reshape(&node(&s, id), None).unwrap(); + let longer = vectorcraft_render::effects::reshape(None, &node(&s, id), None).unwrap(); assert!(longer.geometric_bounds().unwrap().width() > drawn.geometric_bounds().unwrap().width() + 50.0); } diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 92e5d59ec..0dcb818d8 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -916,21 +916,147 @@ fn artboard_display_marks_round_trip_through_the_native_format() { assert!(back.artboards.iter().all(|a| !a.show_center_mark && !a.show_cross_hairs)); } -/// Show Center Mark and Show Cross Hairs are screen-only: SVG and PDF exports (and print, which -/// prints the PDF) come out the same with them on as with them off. +/// Show Center Mark, Show Cross Hairs, Show Video Safe Areas and the Video Ruler Pixel Aspect Ratio +/// are screen-only: SVG and PDF exports (and print, which prints the PDF) come out the same with +/// them on as with them off. #[test] fn artboard_display_marks_stay_out_of_exports() { let mut s = session(); rect(&mut s); let doc = |s: &Session| (*s.doc().unwrap().doc).clone(); let off = doc(&s); - s.execute("artboard.setProps", &json!({"index": 0, "showCenterMark": true, "showCrossHairs": true})).unwrap(); + s.execute( + "artboard.setProps", + &json!({"index": 0, "showCenterMark": true, "showCrossHairs": true, "showVideoSafeAreas": true, "videoRulerPixelAspectRatio": 2}), + ) + .unwrap(); let on = doc(&s); assert_ne!(on.artboards, off.artboards); assert_eq!(vectorcraft_svg::export(&on, &Default::default()), vectorcraft_svg::export(&off, &Default::default())); assert_eq!(crate::export_pdf(&on, &Default::default()).unwrap(), crate::export_pdf(&off, &Default::default()).unwrap()); } +/// Artboard Options' video display options: `artboard.setProps {showVideoSafeAreas, +/// videoRulerPixelAspectRatio}` sets each on that artboard only, in one undo step, also for a +/// locked artboard and in an `artboards` list. A change without them leaves them, a duplicate +/// keeps them, `artboard.new` takes them, `inspect_document` reports them, and a value that isn't +/// true or false, or a ratio outside 0.1–10, is refused and changes nothing. +#[test] +fn artboard_video_display_options_are_set_in_one_undo_step() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let video = |s: &Session, i: usize| { + let a = &s.doc().unwrap().doc.artboards[i]; + (a.show_video_safe_areas, a.video_ruler_pixel_aspect_ratio) + }; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + assert_eq!((video(&s, 0), video(&s, 1)), ((false, 1.0), (false, 1.0)), "off and square by default"); + let u0 = undo(&s); + s.execute("artboard.setProps", &json!({"index": 1, "showVideoSafeAreas": true})).unwrap(); + assert_eq!((video(&s, 0), video(&s, 1)), ((false, 1.0), (true, 1.0)), "only that artboard"); + s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 0.9})).unwrap(); + assert_eq!(video(&s, 1), (true, 0.9)); + s.execute("artboard.setProps", &json!({"index": 1, "name": "Video"})).unwrap(); + assert_eq!(video(&s, 1), (true, 0.9), "a change without them leaves them"); + assert_eq!(undo(&s), u0 + 3, "one undo step each"); + for was in [(true, 0.9), (true, 1.0), (false, 1.0)] { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(video(&s, 1), was); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(video(&s, 1), (true, 1.0)); + // A locked artboard keeps its rectangle, not its video display options. + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 2})).unwrap(); + assert_eq!(video(&s, 0), (false, 2.0)); + let u = undo(&s); + s.execute( + "artboard.setProps", + &json!({"artboards": [{"index": 0, "showVideoSafeAreas": true}, {"index": 1, "showVideoSafeAreas": false, "videoRulerPixelAspectRatio": 1.2}]}), + ) + .unwrap(); + assert_eq!((video(&s, 0), video(&s, 1)), ((true, 2.0), (false, 1.2)), "each listed artboard takes its own"); + assert_eq!(undo(&s), u + 1, "in one undo step"); + let doc = crate::inspect::document(&s); + assert_eq!(doc["artboards"][0]["showVideoSafeAreas"], json!(true)); + assert_eq!( + (doc["artboards"][0]["videoRulerPixelAspectRatio"].clone(), doc["artboards"][1]["videoRulerPixelAspectRatio"].clone()), + (json!(2.0), json!(1.2)) + ); + assert!(doc["artboards"][1].get("showVideoSafeAreas").is_none(), "{doc}"); + s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 1})).unwrap(); + assert!(crate::inspect::document(&s)["artboards"][1].get("videoRulerPixelAspectRatio").is_none(), "square pixels aren't reported"); + let copy = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(video(&s, copy), (true, 2.0), "a duplicate keeps them"); + let made = s.execute("artboard.new", &json!({"showVideoSafeAreas": true, "videoRulerPixelAspectRatio": 0.1})).unwrap()["index"].as_u64().unwrap() + as usize; + assert_eq!(video(&s, made), (true, 0.1), "a new artboard takes them too"); + let before = s.doc().unwrap().doc.clone(); + for bad in [json!("yes"), json!(1)] { + assert!(s.execute("artboard.setProps", &json!({"index": 1, "showVideoSafeAreas": bad})).is_err()); + assert!(s.execute("artboard.new", &json!({"showVideoSafeAreas": bad})).is_err()); + } + for bad in [json!(0.09), json!(10.5), json!(0), json!(-1), json!("2"), json!(null)] { + assert!(s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": bad})).is_err(), "{bad}"); + assert!(s.execute("artboard.new", &json!({"videoRulerPixelAspectRatio": bad})).is_err(), "{bad}"); + } + assert!( + s.execute( + "artboard.setProps", + &json!({"artboards": [{"index": 0, "showVideoSafeAreas": false}, {"index": 1, "videoRulerPixelAspectRatio": 11}]}) + ) + .is_err() + ); + assert_eq!(*s.doc().unwrap().doc, *before, "a bad value changes nothing"); + s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 10})).unwrap(); + assert_eq!(video(&s, 1), (false, 10.0), "10 is in range"); +} + +/// The native format keeps Show Video Safe Areas and the Video Ruler Pixel Aspect Ratio. A +/// document whose artboards hide the safe areas and have square pixels is written byte for byte as +/// before they could be set, a file written without the two keys opens with the safe areas off +/// and square pixels, and a file's ratio outside 0.1–10 opens brought into that range. +#[test] +fn artboard_video_display_options_round_trip_through_the_native_format() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false); + let before = save(&s); + s.execute( + "artboard.setProps", + &json!({"artboards": [{"index": 0, "showVideoSafeAreas": true}, {"index": 1, "videoRulerPixelAspectRatio": 0.9}]}), + ) + .unwrap(); + let bytes = save(&s); + let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let ab = |i: usize| saved["document"]["artboards"][i].clone(); + assert_eq!(ab(0)["show_video_safe_areas"], json!(true)); + assert!(ab(0).get("video_ruler_pixel_aspect_ratio").is_none(), "square pixels aren't written: {}", ab(0)); + assert_eq!(ab(1)["video_ruler_pixel_aspect_ratio"], json!(0.9)); + assert!(ab(1).get("show_video_safe_areas").is_none(), "hidden safe areas aren't written: {}", ab(1)); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.artboards, s.doc().unwrap().doc.artboards); + assert_eq!((back.artboards[0].show_video_safe_areas, back.artboards[1].video_ruler_pixel_aspect_ratio), (true, 0.9)); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showVideoSafeAreas": false}, {"index": 1, "videoRulerPixelAspectRatio": 1}]})) + .unwrap(); + assert_eq!(save(&s), before, "off and square again, the file is as it was"); + // A file whose artboards have no such keys (older writers) opens with them off and square. + let mut old: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + for a in old["document"]["artboards"].as_array_mut().unwrap() { + let a = a.as_object_mut().unwrap(); + a.remove("show_video_safe_areas"); + a.remove("video_ruler_pixel_aspect_ratio"); + } + let back = vectorcraft_format::load(&serde_json::to_vec(&old).unwrap()).unwrap(); + assert!(back.artboards.iter().all(|a| !a.show_video_safe_areas && a.video_ruler_pixel_aspect_ratio == 1.0)); + for (written, opens) in [(json!(0), 0.1), (json!(-3), 0.1), (json!(1e9), 10.0), (json!(2.5), 2.5)] { + let mut file: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + file["document"]["artboards"][1]["video_ruler_pixel_aspect_ratio"] = written.clone(); + let back = vectorcraft_format::load(&serde_json::to_vec(&file).unwrap()).unwrap(); + assert_eq!(back.artboards[1].video_ruler_pixel_aspect_ratio, opens, "{written}"); + } +} + #[test] fn swatch_groups_duplicate_sort() { let mut s = session(); diff --git a/crates/engine/src/tests_params.rs b/crates/engine/src/tests_params.rs index 5bc059ed2..a1f8e00cc 100644 --- a/crates/engine/src/tests_params.rs +++ b/crates/engine/src/tests_params.rs @@ -56,7 +56,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { assert_eq!(refusal("select.all", json!({"zzz": 1})), "unknown parameter `zzz`; the command takes no parameters"); assert_eq!( refusal("shape.line", json!({"x1": 0, "y1": 0, "x2": 5, "y2": 5, "colour": "red"})), - "unknown parameter `colour`; the command takes: x1, y1, x2, y2" + "unknown parameter `colour`; the command takes: x1, y1, x2, y2, fill" ); for (id, ok) in [ ("file.new", json!({"width": 300, "title": "a", "artboardLayout": {"anything": 1}})), diff --git a/crates/engine/src/tests_pathops.rs b/crates/engine/src/tests_pathops.rs index a563075e1..1cda89bad 100644 --- a/crates/engine/src/tests_pathops.rs +++ b/crates/engine/src/tests_pathops.rs @@ -856,6 +856,7 @@ fn create_outlines_matches_text_bounds() { /// share its pixels. #[test] fn create_outlines_turns_bitmap_glyphs_into_images() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); let mut s = session(); @@ -886,11 +887,12 @@ fn create_outlines_turns_bitmap_glyphs_into_images() { /// A glyph image whose key another image of the document holds gets a key of its own. #[test] fn create_outlines_keeps_an_image_already_under_a_glyph_images_key() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font, solid_png}; vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); let mut s = session(); let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE)); - let taken = format!("glyph-{}", glyph.content_key()); + let taken = glyph.content_key(); let other = vectorcraft_doc::ImageBlob::png(solid_png(4, [0, 255, 0, 255])); std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.insert(taken.clone(), other.clone()); s.execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)})).unwrap(); @@ -1078,6 +1080,7 @@ fn offset_path_that_leaves_nothing_says_so() { /// composite's blend mode kept, every node with an id of the document's. #[test] fn create_outlines_turns_colr_glyphs_into_layer_groups() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font}; vectorcraft_text::FontDb::global().add_font(colr_font(true).unwrap()); let mut s = session(); diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 1d33e9d56..787c6ceb2 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -419,15 +419,63 @@ fn svg_export_writes_pattern_def() { assert!(svg[start..end].contains(" + + + + + + + +"##; + let d = vectorcraft_svg::import(svg).unwrap(); + let rep = vectorcraft_pdf::export_with_report(&d, &Default::default()).unwrap(); assert!(rep.warnings.iter().all(|w| !w.contains("pattern")), "{:?}", rep.warnings); - assert!(rep.bytes.len() > 500); + assert!(rep.bytes.len() < 8_000, "{} bytes", rep.bytes.len()); + let plain = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + assert!(String::from_utf8_lossy(&plain).contains("/PatternType 1")); + let back = vectorcraft_pdf::import(&rep.bytes).unwrap(); + assert_eq!(back.patterns.len(), 1); + assert_eq!(back.patterns[0].art.len(), 2, "the hatch's two stripes"); + let mut leaves = vec![]; + back.walk(|n| { + if !n.is_container() { + leaves.push(n.appearance.fill_paint()); + } + }); + assert!(leaves.len() <= 2, "{leaves:?}"); + assert!(leaves.iter().any(|p| matches!(p, Paint::Pattern { pattern, .. } if *pattern == back.patterns[0].name)), "{leaves:?}"); +} + +/// A pattern fill nested more than four patterns deep is written as its tiles, and the export says so. +#[test] +fn pdf_export_writes_patterns_nested_too_deep_as_tiles_with_a_warning() { + use std::sync::Arc; + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{Appearance, Document}; + let square = |paint: Paint| { + Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)) + }; + let mut d = Document::new(100.0, 100.0); + d.patterns.push(PatternDef::new("P0", vec![Arc::new(square(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))))])); + for i in 1..=4 { + d.patterns.push(PatternDef::new(&format!("P{i}"), vec![Arc::new(square(pattern_paint(&format!("P{}", i - 1))))])); + } + // P3 nests four tiling patterns (P3 to P0); P4 one more, which is written as its tiles. + for (top, tiles) in [("P3", false), ("P4", true)] { + let mut d = d.clone(); + let mut n = square(pattern_paint(top)); + n.id = d.alloc_id(); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let rep = vectorcraft_pdf::export_with_report(&d, &Default::default()).unwrap(); + assert_eq!(rep.warnings.iter().any(|w| w.contains("are written as their tiles")), tiles, "{top}: {:?}", rep.warnings); + } } #[test] @@ -504,7 +552,7 @@ fn radial_repeat_wraps_selection() { let RepeatKind::Radial { instances, radius, center, .. } = spec.kind else { panic!() }; assert_eq!((instances, radius), (8, 50.0)); assert!(close(center, Point::new(200.0, 150.0))); - assert_eq!(spec.transforms().len(), 8); + assert_eq!(spec.transforms(None).len(), 8); assert_eq!(n.kind_label(), "Radial Repeat"); } @@ -544,7 +592,7 @@ fn radial_instance_transforms_rotate_around_center() { sel(&mut s, &[r]); let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); let NodeKind::Repeat(spec) = node(&s, id_of(&v)).kind else { panic!() }; - let t = spec.transforms(); + let t = spec.transforms(None); let c = Point::new(200.0, 100.0); assert!(close(t[0] * c, Point::new(200.0, 100.0))); assert!(close(t[1] * c, Point::new(250.0, 150.0))); @@ -583,7 +631,7 @@ fn object_expand_expands_repeats() { sel(&mut s, &[r]); let id = id_of(&s.execute(cmd, &opts).unwrap()); let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!("{cmd}") }; - let n = spec.expand().len(); + let n = spec.expand(None).len(); assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true), "{cmd}"); let undo = s.doc().unwrap().history.undo.len(); s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap(); @@ -632,7 +680,7 @@ fn grid_repeat_and_options() { let RepeatKind::Grid { rows, cols, flip_cols, grid_type, .. } = spec.kind else { panic!() }; assert_eq!((rows, cols, flip_cols), (2, 4, true)); assert_eq!(grid_type, TileType::BrickByRow { offset: 0.5 }); - assert_eq!(spec.expand().len(), 8); + assert_eq!(spec.expand(None).len(), 8); } #[test] @@ -788,7 +836,7 @@ fn mirror_axis_drag_previews_once_and_undo_redo_preserve_source() { let after = node(&s, id); let (NodeKind::Repeat(a), NodeKind::Repeat(b)) = (&before.kind, &after.kind) else { panic!() }; assert_eq!(a.source, b.source, "moving the mirror axis leaves the source artwork in place"); - assert_ne!(a.transforms(), b.transforms(), "the reflected copy updates live"); + assert_ne!(a.transforms(None), b.transforms(None), "the reflected copy updates live"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), before); s.execute("edit.redo", &json!({})).unwrap(); diff --git a/crates/engine/src/tests_pdffidelity.rs b/crates/engine/src/tests_pdffidelity.rs index 21acfe4a7..35fe314a2 100644 --- a/crates/engine/src/tests_pdffidelity.rs +++ b/crates/engine/src/tests_pdffidelity.rs @@ -1,5 +1,5 @@ -//! PDF vector fidelity: pattern strokes are written as their tiles clipped to the stroke's -//! outline, freeform gradients as images of their colour field clipped to what they paint, and +//! PDF vector fidelity: pattern fills and strokes are written as tiling patterns (strokes clipped +//! to the stroke's outline), freeform gradients as images of their colour field clipped to what they paint, and //! both read back looking like the canvas (within 2% at 150 ppi), with nothing to report. use serde_json::{Value, json}; @@ -55,7 +55,7 @@ fn assert_faithful(what: &str, s: &Session, image: bool, rough: &Document) { } #[test] -fn pattern_strokes_are_tiles_clipped_to_the_stroke() { +fn pattern_strokes_are_tiling_patterns_clipped_to_the_stroke() { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 6, "height": 6}))["id"].clone(); @@ -73,6 +73,23 @@ fn pattern_strokes_are_tiles_clipped_to_the_stroke() { assert_faithful("pattern stroke", &s, false, &repainted(&s, &frame, None, Some(grey))); } +/// A brick tiling whose art reaches over the next tiles repeats as one period of the tiling (#3849). +#[test] +fn brick_tilings_with_overlapping_tiles_are_tiling_patterns() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [dot]})); + run(&mut s, "paint.setFill", json!({"color": "#d02020"})); + run(&mut s, "object.pattern.make", json!({"name": "Bricks", "tileType": "brickByRow", "width": 10, "height": 10})); + run(&mut s, "object.pattern.done", json!({})); + let frame = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 140, "height": 120}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [frame]})); + run(&mut s, "paint.setFill", json!({"swatch": "Bricks"})); + let grey = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + assert_faithful("brick fill", &s, false, &repainted(&s, &frame, Some(grey), None)); +} + #[test] fn freeform_gradients_are_images_of_their_colour_field() { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs index a3de3debf..896db4eef 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests_pdfraster.rs @@ -103,7 +103,15 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); let im = Node::new( d.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key: "pic".into(), + width: 40, + height: 30, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); add(&mut d, with(im, shadow())); assert_written("image", &d); diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests_persp_text.rs index 88c9ac1fe..846306fc2 100644 --- a/crates/engine/src/tests_persp_text.rs +++ b/crates/engine/src/tests_persp_text.rs @@ -26,7 +26,7 @@ fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { /// The art the renderer and exporters draw for `id` (its outlines, projected). fn drawn_bounds(s: &Session, id: NodeId) -> Rect { let n = node(s, id); - vectorcraft_render::effects::reshape(&n, None).unwrap().geometric_bounds().unwrap() + vectorcraft_render::effects::reshape(None, &n, None).unwrap().geometric_bounds().unwrap() } #[test] @@ -39,7 +39,7 @@ fn type_in_perspective_is_projected_and_stays_type() { let h = n.projection().expect("drawn through a projection"); // Really foreshortened: the right plane recedes to the right, so the letters' far end is // shorter than their near end. - let out = vectorcraft_render::effects::reshape(&n, None).unwrap(); + let out = vectorcraft_render::effects::reshape(None, &n, None).unwrap(); let mut pts = vec![]; out.walk(&mut |c| { if let Some(p) = c.path_data() { diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 3e9676d29..a47bf56c2 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -616,6 +616,30 @@ fn spacing_guides_space_a_dragged_square_like_its_row() { assert_eq!(drag(&mut s), (161.0, 0), "off: where it was dragged"); } +/// Smart Guides › Invoke Distance Guides: with a square selected, Alt held over another square to +/// its right shows the gap between them in the document's General unit; with the preference or +/// View › Smart Guides off, nothing. +#[test] +fn invoke_distance_guides_measures_the_selection_to_the_object_under_alt() { + use vectorcraft_tools::{Mods, Overlay, PointerKind::Move}; + let mut s = new_doc(); + let a = square(&mut s, 100.0, 100.0); + square(&mut s, 250.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let shown = |s: &mut Session, v: ViewInfo| { + s.pointer(&PointerEvent::new(Move, 275.0, 125.0).with_mods(Mods { alt: true, ..Mods::default() }), v).unwrap(); + s.overlays(v).into_iter().filter_map(|o| if let Overlay::Distance { text, .. } = o { Some(text) } else { None }).collect::>() + }; + assert_eq!(shown(&mut s, v), ["100.00 pt"], "the 100 pt gap; the tops and bottoms are level"); + s.execute("document.setUnits", &json!({"units": "Millimeters"})).unwrap(); + assert_eq!(shown(&mut s, v), ["35.28 mm"]); + assert!(shown(&mut s, ViewInfo { smart_guides: false, ..v }).is_empty(), "View › Smart Guides off"); + set_pref(&mut s, "distanceGuides", json!(false)); + assert!(shown(&mut s, v).is_empty(), "Invoke Distance Guides off"); +} + /// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement /// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a /// square is dragged into line with another, and how far the pull reaches; a hidden guide still diff --git a/crates/engine/src/tests_previewbounds.rs b/crates/engine/src/tests_previewbounds.rs index 88b87394d..95440f2ae 100644 --- a/crates/engine/src/tests_previewbounds.rs +++ b/crates/engine/src/tests_previewbounds.rs @@ -96,7 +96,7 @@ fn outlined_type(s: &mut Session, x: f64, text: &str) -> NodeId { /// The box of the glyph outlines that type `id` with Outline Object is drawn as. fn glyphs(s: &Session, id: NodeId) -> Rect { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); - vectorcraft_render::effects::reshape(&n, None).unwrap().geometric_bounds().unwrap() + vectorcraft_render::effects::reshape(None, &n, None).unwrap().geometric_bounds().unwrap() } fn near(a: Rect, b: Rect, tolerance: f64) -> bool { diff --git a/crates/engine/src/tests_select_ids_first.rs b/crates/engine/src/tests_select_ids_first.rs index fbeb2c867..03d4ebd0b 100644 --- a/crates/engine/src/tests_select_ids_first.rs +++ b/crates/engine/src/tests_select_ids_first.rs @@ -143,6 +143,24 @@ fn bad_ids_are_refused_and_change_nothing() { } } +#[test] +fn a_command_that_fails_after_selecting_its_ids_puts_the_selection_back() { + let (mut s, ids) = doc(); + let far = s.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [ids[3]]})).unwrap(); + let (before, layer) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().active_layer); + // The ids are fine, so they are selected; the shapes don't overlap, so Intersect then fails. + let e = s.execute("object.pathfinder.intersect", &json!({"ids": [ids[0], far]})).unwrap_err().to_string(); + assert!(e.contains("empty result"), "{e}"); + assert!(s.doc().unwrap().doc == before); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(ids[3])], "the selection stays"); + assert_eq!(s.doc().unwrap().active_layer, layer); + // Without ids the failing command leaves the selection it ran on, as before. + s.execute("select.set", &json!({"ids": [ids[0], far]})).unwrap(); + assert!(s.execute("object.pathfinder.intersect", &json!({})).is_err()); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(ids[0]), NodeId(far)]); +} + #[test] fn without_ids_the_selection_is_used_as_before() { let (mut s, ids) = doc(); diff --git a/crates/engine/src/tests_simplify_part.rs b/crates/engine/src/tests_simplify_part.rs new file mode 100644 index 000000000..4f600f68a --- /dev/null +++ b/crates/engine/src/tests_simplify_part.rs @@ -0,0 +1,223 @@ +//! Simplify of the direct-selected part of a path: with anchors or segments of a path +//! direct-selected, `object.path.simplify` refits only each run of selected segments between its end +//! anchors, as the Simplify help page describes for part of a path picked with Direct Selection +//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html). + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::kurbo::ParamCurveNearest; +use vectorcraft_geom::{ParamCurve, PathData, SubPath}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path_of(s: &Session, id: NodeId) -> PathData { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Path { path, .. } => path.clone(), + _ => panic!("not a path"), + } +} + +/// A path through `points`, closed or open, selected whole: its id. +fn polyline(s: &mut Session, points: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = points.iter().map(|&(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()) +} + +/// A sine wave of 101 anchors, 3 pt apart, 40 pt high. +fn wave(s: &mut Session) -> NodeId { + let pts: Vec<(f64, f64)> = (0..=100).map(|i| (f64::from(i) * 3.0, 200.0 + 40.0 * (f64::from(i) * 3.0 / 50.0).sin())).collect(); + polyline(s, &pts, false) +} + +/// Direct-select anchors `ranges` (inclusive) of the first subpath of `id`. +fn select_anchors(s: &mut Session, id: NodeId, ranges: &[(usize, usize)]) { + let anchors: Vec = ranges.iter().flat_map(|&(a, b)| (a..=b).map(|i| json!([0, i]))).collect(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors})).unwrap(); +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The farthest any of 11 points along each of `original`'s segments `segs` lies from `fitted`'s +/// segments `fit`. +fn worst_offset(original: &SubPath, segs: impl Iterator, fitted: &SubPath, fit: std::ops::Range) -> f64 { + let off = |p| fit.clone().map(|i| fitted.segment(i % fitted.segment_count()).nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + segs.flat_map(|i| (0..=10).map(move |k| original.segment(i).eval(f64::from(k) / 10.0))).map(off).fold(0.0, f64::max) +} + +/// With anchors 20 to 60 of a 101-anchor wave direct-selected, Simplify refits only the 40 +/// segments between them: anchors 0–19 and 61–100 and the segments outside the run stay exactly as +/// they were, anchors 20 and 60 stay where they were with their handles on the side away from the +/// run, the run stays within the tolerance of the original, and it stays direct-selected. It is one +/// undo step, and undo brings back the path and the selection. +#[test] +fn simplify_refits_only_the_run_between_direct_selected_anchors() { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(20, 60)]); + let (doc, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), undo_len(&s)); + let out = s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + let p = path_of(&s, id); + let sp = &p.subpaths[0]; + let len = sp.anchors.len(); + assert_eq!(out["after"].as_u64(), Some(len as u64)); + assert!(len < 101 - 30, "{len}"); + let end = len - 41; + assert_eq!(sp.anchors[..20], original.anchors[..20], "the anchors before the run"); + assert_eq!(sp.anchors[end + 1..], original.anchors[61..], "the anchors after the run"); + assert_eq!((sp.anchors[20].p, sp.anchors[20].h_in), (original.anchors[20].p, original.anchors[20].h_in)); + assert_eq!((sp.anchors[end].p, sp.anchors[end].h_out), (original.anchors[60].p, original.anchors[60].h_out)); + for i in 0..20 { + assert_eq!(sp.segment(i), original.segment(i)); + assert_eq!(sp.segment(end + i), original.segment(60 + i)); + } + assert!(worst_offset(&original, 20..60, sp, 20..end) <= 1.0 + 1e-6); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect()), "the run stays direct-selected"); + assert_eq!(undo_len(&s), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); +} + +/// Two runs of one path simplify independently, in one undo step: the result is the first run as +/// Simplify makes it alone, the anchors between the runs as they were, and the second run as +/// Simplify makes it alone. +#[test] +fn separate_runs_on_one_path_simplify_independently_in_one_step() { + let alone = |ranges: &[(usize, usize)]| { + let mut s = session(); + let id = wave(&mut s); + select_anchors(&mut s, id, ranges); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + path_of(&s, id).subpaths[0].anchors.clone() + }; + let (first, second) = (alone(&[(10, 30)]), alone(&[(60, 80)])); + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(10, 30), (60, 80)]); + let undo = undo_len(&s); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + assert_eq!(undo_len(&s), undo + 1, "one undo step"); + let both = path_of(&s, id).subpaths[0].anchors.clone(); + // Where old anchor 30 is in the first result, and old anchor 80 in the second. + let (a30, a80) = (first.len() - 71, second.len() - 21); + assert!(a30 < 30 && a80 < 80, "both runs lose anchors"); + let mid = a30 + 30; + assert_eq!(both[..=a30], first[..=a30]); + assert_eq!(both[a30 + 1..mid], original.anchors[31..60], "the anchors between the runs"); + assert_eq!(both[mid..], second[60..]); + let sel: BTreeSet<_> = (10..=a30).chain(mid..=mid + a80 - 60).map(|i| (0, i)).collect(); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel)); +} + +/// On a closed 80-gon, a run from anchor 70 through the start point to anchor 10 simplifies as one +/// run: anchor 0 goes, anchors 11–69 stay exactly as they were (the path now starts at anchor 10), +/// anchors 10 and 70 stay where they were with their outer handles, and the path stays closed. +#[test] +fn a_run_across_the_start_of_a_closed_path_simplifies_as_one() { + let mut s = session(); + let pts: Vec<(f64, f64)> = + (0..80).map(|i| (f64::from(i) * std::f64::consts::TAU / 80.0).sin_cos()).map(|(y, x)| (300.0 + 100.0 * x, 300.0 + 100.0 * y)).collect(); + let id = polyline(&mut s, &pts, true); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(70, 79), (0, 10)]); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + let sp = path_of(&s, id).subpaths[0].clone(); + assert!(sp.closed); + assert!(sp.anchors.len() < 80 - 10, "{}", sp.anchors.len()); + assert!(!sp.anchors.iter().any(|a| a.p == original.anchors[0].p), "anchor 0 was inside the run"); + assert_eq!(sp.anchors[1..60], original.anchors[11..70]); + assert_eq!((sp.anchors[0].p, sp.anchors[0].h_out), (original.anchors[10].p, original.anchors[10].h_out)); + assert_eq!((sp.anchors[60].p, sp.anchors[60].h_in), (original.anchors[70].p, original.anchors[70].h_in)); + for i in 0..60 { + assert_eq!(sp.segment(i), original.segment(10 + i)); + } + let n = sp.anchors.len(); + assert!(worst_offset(&original, (70..80).chain(0..10), &sp, 60..n) <= 1.0 + 1e-6); + let sel: BTreeSet<_> = std::iter::once((0, 0)).chain((60..n).map(|i| (0, i))).collect(); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel)); +} + +/// Segments picked with the Direct Selection tool simplify the same way, and stay picked as +/// segments. +#[test] +fn picked_segments_simplify_and_stay_picked() { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + let segs: Vec = (20..60).map(|i| json!([0, i])).collect(); + s.execute("select.segments", &json!({"id": id.0, "segments": segs})).unwrap(); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + let sp = path_of(&s, id).subpaths[0].clone(); + let end = sp.anchors.len() - 41; + assert_eq!(sp.anchors[..20], original.anchors[..20]); + assert_eq!(sp.anchors[end + 1..], original.anchors[61..]); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.segments.get(&id), Some(&(20..end).map(|i| (0, i)).collect())); + assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect())); +} + +/// `wholePaths` simplifies a partly selected path whole, as Simplify with it selected whole does. +#[test] +fn whole_paths_ignores_the_direct_selection() { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id); + select_anchors(&mut s, id, &[(20, 60)]); + s.execute("object.path.simplify", &json!({"tolerance": 1, "wholePaths": true})).unwrap(); + let opts = vectorcraft_pathops::SimplifyOptions { tolerance: 1.0, ..Default::default() }; + assert_eq!(path_of(&s, id), vectorcraft_pathops::simplify_with(&original, &opts)); + assert!(s.doc().unwrap().selection.anchors.is_empty()); +} + +/// The Simplify slider's preview, at a level or at Auto-Simplify's, simplifies only the run too, and +/// committing it is what the command does. +#[test] +fn slider_preview_and_auto_simplify_only_the_run() { + for p in [json!({"level": 40}), json!({"auto": true})] { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(20, 60)]); + let undo = undo_len(&s); + s.begin_interaction("Simplify").unwrap(); + s.preview("object.path.simplify", &json!({"level": 90})).unwrap(); + s.preview("object.path.simplify", &p).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(undo_len(&s), undo + 1, "{p}"); + let previewed = path_of(&s, id); + let sp = &previewed.subpaths[0]; + assert!(sp.anchors.len() < 101, "{p}"); + assert_eq!(sp.anchors[..20], original.anchors[..20], "{p}"); + assert_eq!(sp.anchors[sp.anchors.len() - 40..], original.anchors[61..], "{p}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.path.simplify", &p).unwrap(); + assert_eq!(path_of(&s, id), previewed, "{p}"); + } +} + +/// A run Simplify can't make simpler (two sides of a rectangle, which meet at a corner) is left as +/// it is: no undo step, and the rectangle stays a live shape. +#[test] +fn a_run_that_cannot_be_simpler_changes_nothing() { + let mut s = session(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap()); + let before = s.doc().unwrap().doc.clone(); + select_anchors(&mut s, id, &[(0, 2)]); + let undo = undo_len(&s); + s.execute("object.path.simplify", &json!({"tolerance": 5})).unwrap(); + assert_eq!(undo_len(&s), undo); + assert_eq!(s.doc().unwrap().doc, before); +} diff --git a/crates/engine/src/tests_symbollib.rs b/crates/engine/src/tests_symbollib.rs new file mode 100644 index 000000000..eed7229ef --- /dev/null +++ b/crates/engine/src/tests_symbollib.rs @@ -0,0 +1,227 @@ +//! Symbol libraries: the built-in library, saving a document's symbols as a library (User +//! Defined, or a file), reopening it and adding its symbols to another document (nested symbols, +//! patterns and Symbol Options along, one undo step, deduped, renamed on a clash, placed), other +//! documents' symbols, and files that can't be read. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, SymbolRegistration}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn symbols(s: &Session) -> Vec { + doc(s).symbols.iter().map(|x| x.name.clone()).collect() +} + +fn strs(v: &Value) -> Vec<&str> { + v.as_array().unwrap().iter().map(|n| n.as_str().unwrap()).collect() +} + +/// A folder of its own under the temp folder, emptied. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vc-symbollib-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// A document with "Star" (a red star), "Badge" (a circle with a Star instance in it, registration +/// top left) and "Dots" (a square painted with pattern "Dots"). +fn kit() -> Session { + let mut s = session(); + run(&mut s, "shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 8})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "symbol.new", json!({"name": "Star"})); + let star = s.doc().unwrap().selection.objects.clone(); + let ring = run(&mut s, "shape.ellipse", json!({"x": 20, "y": 20, "width": 60, "height": 60}))["id"].as_u64().unwrap(); + let mut ids: Vec = star.iter().map(|i| i.0).collect(); + ids.push(ring); + run(&mut s, "symbol.new", json!({"name": "Badge", "ids": ids, "registration": 0})); + s.edit("Pattern", |d, _| { + let dot = vectorcraft_doc::Node::path( + d.alloc_id(), + vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0)), + vectorcraft_doc::Appearance::default_art(), + ); + let def = vectorcraft_doc::PatternDef { + tile: vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0), + ..vectorcraft_doc::PatternDef::new("Dots", vec![std::sync::Arc::new(dot)]) + }; + d.patterns.push(def); + d.swatches.push(vectorcraft_color::Swatch { + name: "Dots".into(), + paint: vectorcraft_color::Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, + global: false, + spot: false, + }); + Ok(()) + }) + .unwrap(); + run(&mut s, "shape.rectangle", json!({"x": 120, "y": 120, "width": 40, "height": 30})); + run(&mut s, "paint.setFill", json!({"swatch": "Dots"})); + run(&mut s, "symbol.new", json!({"name": "Dots"})); + s +} + +#[test] +fn the_builtin_library_is_original_art_generated_in_code() { + let mut s = Session::new(); + let list = run(&mut s, "symbol.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + assert_eq!(libs.len(), 1, "{libs:?}"); + assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn"))); + let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]); + assert_eq!(libs[0]["count"].as_u64(), Some(names.len() as u64)); + let arrow = lib["symbols"].as_array().unwrap().iter().find(|x| x["name"] == "Arrow").unwrap(); + assert_eq!(arrow["size"], json!([48.0, 32.0])); + assert!(s.execute("symbol.library", &json!({"library": "nope"})).is_err()); +} + +#[test] +fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() { + let dir = folder("user"); + let mut s = kit(); + s.symbol_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + // Saving the Badge brings the Star it nests, not Dots. + let r = run(&mut s, "symbol.saveLibrary", json!({"user": true, "name": "Signs", "names": ["Badge"]})); + assert_eq!((r["library"].as_str(), r["count"].as_u64()), (Some("user/Signs.vectorcraft"), Some(2))); + let path = r["path"].as_str().unwrap().to_string(); + let list = run(&mut s, "symbol.libraries", json!({})); + let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect(); + assert_eq!(user.len(), 1); + assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("Signs"), Some(2))); + // The file is a document: its symbols, no art. + let bytes = std::fs::read(&path).unwrap(); + let saved = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(saved.symbols.iter().map(|x| x.name.as_str()).collect::>(), ["Star", "Badge"]); + assert!(saved.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty()))); + // Loading the file gives that User Defined library. + let r = run(&mut s, "symbol.loadLibrary", json!({ "path": path })); + assert_eq!((r["library"].as_str(), r["name"].as_str()), (Some("user/Signs.vectorcraft"), Some("Signs"))); + + // Into a new document: Badge and the Star in it, with Badge's registration, as one step. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": "Signs", "name": "Badge"})); + assert_eq!((strs(&r["names"]), strs(&r["added"]), strs(&r["existing"])), (vec!["Badge"], vec!["Badge"], vec![])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Add to Symbols"); + assert_eq!(symbols(&s), ["Star", "Badge"]); + assert_eq!(doc(&s).symbol_registration("Badge"), SymbolRegistration(0)); + let (_, lib) = cmd::symbollib::library(&s, "Signs").unwrap(); + assert_eq!(doc(&s).symbols, lib.doc.symbols); + assert_eq!(doc(&s).unknown.get("symbolSizes").and_then(|m| m.get("Badge")), lib.doc.unknown.get("symbolSizes").and_then(|m| m.get("Badge"))); + assert_eq!(run(&mut s, "symbol.list", json!({}))["current"], "Badge"); + // Undo takes it all back. + run(&mut s, "edit.undo", json!({})); + assert!(symbols(&s).is_empty()); + run(&mut s, "edit.redo", json!({})); + // Again: already there, so no step. + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": "user/Signs.vectorcraft", "names": ["Badge", "Star", "Badge"]})); + assert_eq!((strs(&r["names"]), strs(&r["existing"])), (vec!["Badge", "Star"], vec!["Badge", "Star"])); + assert_eq!((undo_len(&s), symbols(&s).len()), (before, 2)); + assert_eq!(run(&mut s, "symbol.list", json!({}))["current"], "Badge"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_library_file_brings_patterns_renames_clashes_and_places_an_instance() { + let dir = folder("file"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Kit.vectorcraft"); + let mut s = kit(); + let r = run(&mut s, "symbol.saveLibrary", json!({"path": file.to_string_lossy()})); + assert_eq!(r["count"].as_u64(), Some(3)); + let lib = run(&mut s, "symbol.loadLibrary", json!({"path": file.to_string_lossy()})); + assert_eq!((lib["name"].as_str(), lib["count"].as_u64()), (Some("Kit"), Some(3))); + let id = lib["library"].as_str().unwrap().to_string(); + assert!(id.starts_with("loaded/")); + + // A document with another "Star" of its own. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "symbol.new", json!({"name": "Star"})); + let mine = doc(&s).symbols[0].clone(); + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": id, "names": ["Star", "Dots"], "place": true, "x": 30, "y": 40})); + assert_eq!(undo_len(&s), before + 1, "adding and placing are one step"); + assert_eq!(strs(&r["names"]), ["Star 2", "Dots"]); + assert_eq!(doc(&s).symbols[0], mine, "the document's own Star stays"); + // The pattern came along, with its swatch; the placed instance is the first one, selected. + assert!(doc(&s).pattern("Dots").is_some() && doc(&s).swatch("Dots").is_some()); + let placed = NodeId(r["placed"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().selection.objects, [placed]); + let NodeKind::SymbolInstance { symbol, .. } = &doc(&s).node(placed).unwrap().kind else { panic!("not an instance") }; + assert_eq!(symbol, "Star 2"); + let b = doc(&s).node(placed).unwrap().geometric_bounds().unwrap(); + assert!((b.center().x - 30.0).abs() < 0.01 && (b.center().y - 40.0).abs() < 0.01, "{b:?}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(symbols(&s), ["Star"]); + assert!(doc(&s).node(placed).is_none() && doc(&s).pattern("Dots").is_none()); + // Placing a symbol the document has still places (one step), without adding it again. + run(&mut s, "edit.redo", json!({})); + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": id, "name": "Dots", "place": true})); + assert_eq!((undo_len(&s), strs(&r["existing"])), (before + 1, vec!["Dots"])); + assert!(r["placed"].is_u64()); + assert!(s.execute("symbol.addFromLibrary", &json!({"library": id, "name": "Nope"})).is_err()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn other_documents_symbols_load_and_unreadable_files_are_errors() { + let mut s = kit(); + // Another document's symbols load as a library, without its art. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "symbol.loadLibrary", json!({"dataBase64": b64, "name": "Poster.vectorcraft"})); + assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Poster"), Some(3))); + let (_, lib) = cmd::symbollib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert!(lib.doc.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty()))); + // So do an SVG's. + let svg = r##""##; + let r = run(&mut s, "symbol.loadLibrary", json!({"data": svg, "name": "Dots.svg"})); + assert_eq!(r["count"].as_u64(), Some(1), "{r}"); + // Files that aren't documents, missing files and damaged native files are errors. + assert!(s.execute("symbol.loadLibrary", &json!({"data": "hello", "name": "x.txt"})).is_err()); + assert!(s.execute("symbol.loadLibrary", &json!({"path": "/no/such/Library.vectorcraft"})).is_err()); + let dir = folder("bad"); + std::fs::create_dir_all(&dir).unwrap(); + let bad = dir.join("Broken.vectorcraft"); + std::fs::write(&bad, b"{\"format\": \"vectorcraft\", \"doc\": 7").unwrap(); + assert!(s.execute("symbol.loadLibrary", &json!({"path": bad.to_string_lossy()})).is_err()); + // A damaged file in the user folder isn't listed. + s.symbol_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let list = run(&mut s, "symbol.libraries", json!({})); + assert!(list["libraries"].as_array().unwrap().iter().all(|l| l["category"] != "user")); + s.symbol_libraries.set_user_dir(None); + let _ = std::fs::remove_dir_all(dir); + // Nothing to save, or no such symbol, is an error; a headless session has no user folder. + assert!(s.execute("symbol.saveLibrary", &json!({"names": ["Nope"]})).is_err()); + assert!(s.execute("symbol.saveLibrary", &json!({"user": true})).is_err()); + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + assert!(s.execute("symbol.saveLibrary", &json!({})).is_err()); + // Without a path the library comes back as the file's bytes. + let mut s = kit(); + let r = run(&mut s, "symbol.saveLibrary", json!({"names": ["Star"]})); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().symbols.len(), 1); +} diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index be7ebbe48..b458f55fd 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -238,6 +238,29 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() { assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there"); } +/// Touch Type on the first character of a list item moves that character and leaves the bullet +/// where it is. The bullet stands for no character, so the first character still moves only up +/// and down. +#[test] +fn touch_type_moves_the_first_character_of_a_list_item_and_not_its_bullet() { + let mut s = session(); + let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet"})).unwrap(); + let pens = |s: &Session| { + let t = obj(s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let bullet = lay.glyphs.iter().find(|g| g.len == 0).map(|g| t.xf * (g.xf * Point::ZERO)).unwrap(); + (bullet, lay.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, t.xf * (g.xf * Point::ZERO))).collect::>()) + }; + let (bullet, before) = pens(&s); + assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dx": 5})).is_err(), "no character before it to kern"); + s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 3})).unwrap(); + let (bullet_after, after) = pens(&s); + assert!(near(moved_by(&before, &after, 0), (0.0, -3.0)), "the T moves 3 pt up: {:?}", moved_by(&before, &after, 0)); + assert!(near(moved_by(&before, &after, 1), (0.0, 0.0))); + assert!((bullet_after - bullet).hypot() < 1e-6, "the bullet stays: {bullet:?} {bullet_after:?}"); +} + /// Raising, scaling or rotating a character leaves it and the characters after it where they /// were along the line, also where the font kerns it with its neighbors (the T and the y). #[test] @@ -546,6 +569,62 @@ fn a_cmd_drag_while_typing_moves_the_type_and_keeps_the_typing() { assert!((after.x0 - moved.x0).abs() < 1e-6, "and stays where it was moved: {after:?}"); } +fn cmd_click(s: &mut Session, x: f64, y: f64) { + let (v, cmd) = (ViewInfo::default(), Mods { cmd: true, ..Default::default() }); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y).with_mods(cmd), v).unwrap(); + } +} + +/// #3779: a Cmd/Ctrl click on another object while typing selects it and ends the text edit, so +/// the keys that follow are shortcuts again and not typing. +#[test] +fn a_cmd_click_on_another_object_while_typing_ends_the_text_edit() { + let mut s = session(); + let v = ViewInfo::default(); + let r = s.execute("shape.rectangle", &json!({"x": 500, "y": 400, "width": 50, "height": 50})).unwrap(); + let rect = NodeId(r["id"].as_u64().unwrap()); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Hello", v).unwrap(); + cmd_click(&mut s, 525.0, 425.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![rect], "the rectangle is selected"); + assert!(!s.tool_wants_text(), "the text edit ended"); + s.tool_text("v", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello", "a later key is not typed into the text"); + assert_eq!(s.tool_id(), "type", "the Type tool is back"); +} + +/// #3779: a Cmd/Ctrl click on empty canvas while typing deselects the text and ends its edit. +#[test] +fn a_cmd_click_on_empty_canvas_while_typing_ends_the_text_edit() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Hello", v).unwrap(); + cmd_click(&mut s, 600.0, 450.0); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing is selected"); + assert!(!s.tool_wants_text(), "the text edit ended"); + s.tool_text("v", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello", "a later key is not typed into the text"); +} + +/// #3779: point type a click placed and nothing was typed into goes away on a Cmd/Ctrl click +/// elsewhere, as it does on Escape. +#[test] +fn a_cmd_click_away_from_empty_point_type_discards_it() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + cmd_click(&mut s, 600.0, 450.0); + assert!(s.doc().unwrap().doc.node(id).is_none(), "the empty type is discarded"); +} + #[test] fn undo_during_a_drag_takes_back_only_the_drag() { let mut s = session(); @@ -1160,3 +1239,33 @@ fn clicking_the_out_port_then_the_canvas_threads_the_text_into_a_new_frame() { assert_eq!(format!("{}{}", obj(&s, id).plain_text(), obj(&s, thread[1]).plain_text()), long); assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]); } + +/// #2572: with the Selection tool, area type whose text fits shows both ports, and a click on its +/// in port then on empty canvas makes a new frame the story starts in, flowing on into the frame +/// clicked, in one undo step; the ports then show the thread's arrows. +#[test] +fn clicking_the_in_port_then_the_canvas_threads_a_new_frame_before_it() { + let mut s = session(); + let v = ViewInfo::default(); + let words = "Words that fit. "; + let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": words, "size": 12, "area": {"width": 100, "height": 40}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", v).unwrap(); + let boxes = |s: &mut Session| { + s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { color: [255, 255, 255, 255], .. })).count() + }; + assert_eq!(boxes(&mut s), 2, "an in port and an out port"); + let steps = undo_labels(&s).len(); + click(&mut s, 50.0, 50.0 + vectorcraft_tools::text::PORT_RISE_PX); + assert_eq!(s.cursor(Point::new(400.0, 400.0), Mods::default(), v), vectorcraft_tools::Cursor::LoadedText); + click(&mut s, 400.0, 400.0); + let doc = &s.doc().unwrap().doc; + let [thread] = &doc.text_threads[..] else { panic!("one thread: {:?}", doc.text_threads) }; + assert_eq!(thread[1], id, "the frame clicked comes second"); + assert_eq!(format!("{}{}", obj(&s, thread[0]).plain_text(), obj(&s, id).plain_text()), words); + assert!(!obj(&s, thread[0]).plain_text().is_empty(), "the story starts in the new frame"); + assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]); + let strokes = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { .. })).count(); + assert_eq!(strokes, 6, "both frames selected: the first one's out port and the second one's in port show arrows"); +} diff --git a/crates/engine/src/tests_trap.rs b/crates/engine/src/tests_trap.rs new file mode 100644 index 000000000..87fb92a6f --- /dev/null +++ b/crates/engine/src/tests_trap.rs @@ -0,0 +1,158 @@ +//! Tests for Pathfinder panel › Trap (`object.pathfinder.trap`). + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Rect; + +use super::*; + +const YELLOW: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 1.0, k: 0.0 }; +const BLUE: Color = Color::Cmyk { c: 1.0, m: 0.6, y: 0.0, k: 0.0 }; + +/// A yellow rectangle (0..50) abutting a blue one (50..100), both 40 pt tall, both selected. +fn abutting() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut rect = |x: f64, c: Color| { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.edit("fill", |d, _| { + if let Some(n) = d.node_mut(id) { + n.appearance.set_fill(Paint::solid(c)); + } + Ok(()) + }) + .unwrap(); + id + }; + let (a, b) = (rect(0.0, YELLOW), rect(50.0, BLUE)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + (s, a, b) +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.02 +} + +/// The traps of the trap group the command made. +fn traps(s: &Session, v: &Value) -> Vec { + let g = node(s, ids(v)[0]); + assert!(matches!(g.kind, NodeKind::Group { .. })); + g.children().unwrap().iter().map(|c| (**c).clone()).collect() +} + +#[test] +fn trap_spreads_the_lighter_colour_into_the_darker_along_the_shared_edge() { + let (mut s, a, b) = abutting(); + let before = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100})).unwrap(); + let t = traps(&s, &r); + assert_eq!(t.len(), 1); + let bb = t[0].geometric_bounds().unwrap(); + assert!(near(bb.x0, 50.0) && near(bb.x1, 52.0) && near(bb.y0, 0.0) && near(bb.y1, 40.0), "2 pt into the blue: {bb:?}"); + let fill = t[0].appearance.fill().unwrap(); + assert_eq!(fill.paint.color(), Some(YELLOW), "filled with the lighter colour"); + assert!(fill.overprint, "set to overprint"); + assert!(t[0].appearance.stroke_paint().is_none()); + // The originals stay; the traps sit directly above the topmost one and are selected. + let st = s.doc().unwrap(); + assert_eq!(node(&s, a), *before.node(a).unwrap()); + assert_eq!(node(&s, b), *before.node(b).unwrap()); + let (parent, ib, _) = st.doc.position(b).unwrap(); + let (gparent, ig, _) = st.doc.position(ids(&r)[0]).unwrap(); + assert_eq!((gparent, ig), (parent, ib + 1)); + assert_eq!(st.selection.objects, ids(&r)); + // One undo step. + assert_eq!(st.history.undo.len(), undo_len + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn height_width_and_tint_reduction_shape_and_tint_the_trap() { + let (mut s, _, _) = abutting(); + let r = s.execute("object.pathfinder.trap", &json!({"thickness": "1 pt", "tintReduction": 25})).unwrap(); + let t = traps(&s, &r); + let [_, _, y, _] = t[0].appearance.fill_paint().color().unwrap().to_cmyk(); + assert!((y - 0.25).abs() < 1e-4, "yellow at 25%: {y}"); + let bb = t[0].geometric_bounds().unwrap(); + assert!(near(bb.x1, 51.0), "{bb:?}"); +} + +#[test] +fn reverse_traps_spread_the_darker_colour_into_the_lighter() { + let (mut s, _, _) = abutting(); + let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100, "reverseTraps": true})).unwrap(); + let t = traps(&s, &r); + let bb = t[0].geometric_bounds().unwrap(); + assert!(near(bb.x0, 48.0) && near(bb.x1, 50.0), "2 pt into the yellow: {bb:?}"); + assert_eq!(t[0].appearance.fill_paint().color(), Some(BLUE)); +} + +#[test] +fn no_adjoining_edges_is_a_clear_error_that_changes_nothing() { + let (mut s, a, b) = abutting(); + s.edit("apart", |d, _| { + if let Some(n) = d.node_mut(b) { + n.kind = NodeKind::Path { + path: vectorcraft_geom::shapes::rectangle(Rect::new(200.0, 0.0, 250.0, 40.0)), + rule: vectorcraft_geom::FillRule::NonZero, + live: None, + clipping: false, + guide: false, + }; + } + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no edges of different colours meet"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + // One object alone has nothing to trap with. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string(); + assert!(e.contains("two or more objects"), "{e}"); + // Given as ids, the objects are selected for the command, and its failure puts the + // selection back. + s.execute("select.none", &json!({})).unwrap(); + let e = s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).unwrap_err().to_string(); + assert!(e.contains("no edges of different colours meet"), "{e}"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "{:?}", s.doc().unwrap().selection.objects); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert!(s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).is_err()); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn out_of_range_and_unreadable_numbers_are_refused_or_defaulted() { + let (mut s, _, _) = abutting(); + for p in [json!({"thickness": 1e300}), json!({"thickness": 0}), json!({"heightWidth": 0}), json!({"tintReduction": 101})] { + assert!(s.execute("object.pathfinder.trap", &p).is_err(), "{p}"); + } + // A thickness that isn't a number falls back to the default. + let r = s.execute("object.pathfinder.trap", &json!({"thickness": "NaN", "tintReduction": 100})).unwrap(); + let bb = traps(&s, &r)[0].geometric_bounds().unwrap(); + assert!(near(bb.x1, 50.25), "{bb:?}"); +} + +#[test] +fn ids_name_the_objects_to_trap() { + let (mut s, a, b) = abutting(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let r = s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(traps(&s, &r).len(), 1); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d32691a02..4ee682f2a 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -169,13 +169,15 @@ impl Session { } /// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the - /// Pen goes on drawing its path). + /// Pen goes on drawing its path), less the work the gesture ended as a command would: a + /// Cmd+click that takes the type being edited out of the selection ends its edit (#3779). pub(crate) fn give_back_tool(&mut self, view: ViewInfo) -> Result<()> { let Some(lender) = self.lender.take() else { return Ok(()) }; let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); let r = self.apply_actions(acts); self.keep_tool_settings(); self.tool = lender; + self.after_command(); r.map(drop) } @@ -280,6 +282,7 @@ impl Session { measurement_labels: self.prefs.measurement_labels, transform_tools_guides: self.prefs.transform_tools_guides, spacing_guides: self.prefs.spacing_guides, + distance_guides: self.prefs.distance_guides, snapping_tolerance: self.prefs.snapping_tolerance, construction_angles: if self.prefs.construction_guides { vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles) @@ -311,6 +314,11 @@ impl Session { /// Feed a pointer event to the active tool. Returns requests for the UI. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { self.last_view = view; + match ev.kind { + PointerKind::Down | PointerKind::Drag => self.pointer_held = true, + PointerKind::Up | PointerKind::Move => self.pointer_held = false, + PointerKind::DoubleClick => {} + } // The Plane Switching Widget takes its clicks whatever the tool. if let Some(r) = self.plane_widget_pointer(ev, view) { return r; @@ -431,10 +439,28 @@ impl Session { self.with_tool_cx(view, |t, cx| t.ime_caret(cx)) } + /// The pointer stopped hovering for the active tool ([`vectorcraft_tools::Tool::hover_ended`]). + pub fn tool_hover_ended(&mut self, view: ViewInfo) { + self.with_tool_cx(view, |t, cx| t.hover_ended(cx)); + } + + /// Whether the active tool's hover depends on what lies under a still pointer + /// ([`vectorcraft_tools::Tool::wants_rehover`]). + pub fn tool_wants_rehover(&self) -> bool { + self.tool.wants_rehover() + } + pub fn tool_busy(&self) -> bool { self.tool.busy() } + /// Is the pointer button held: was the last pointer event a press or a drag, not a release or a + /// move? A tool that is busy while the button is not held is waiting between its clicks (the + /// Dimension tool after its first point). + pub fn pointer_held(&self) -> bool { + self.pointer_held + } + /// Is the active tool moving, scaling or rotating the selection with a drag? pub fn tool_transforming(&self) -> bool { self.tool.transforming() @@ -447,11 +473,11 @@ impl Session { pub fn overlays(&mut self, view: ViewInfo) -> Vec { let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); - // The Type tools draw the overflow markers of selected type themselves. + // The Type tools draw the ports of selected type themselves. if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") { - let mut marks = std::mem::take(&mut self.overflow_marks); - v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx))); - self.overflow_marks = marks; + let mut marks = std::mem::take(&mut self.port_marks); + v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::thread_ports(&mut marks, cx))); + self.port_marks = marks; } if let Some(g) = self.ruler_guide.take() { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); @@ -463,6 +489,10 @@ impl Session { let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position }); v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place)); } + // Global Edit: the objects edited with the selected one are highlighted. + for (path, color) in crate::cmd::globaledit::highlights(self) { + v.push(Overlay::Path { path, color, width: 1.0, dashed: false }); + } if let Some((cmd, p)) = deferred_preview { v.extend(self.deferred_preview_overlays_from_command(&cmd, &p)); } @@ -534,6 +564,13 @@ impl Session { if let Some(k) = p.get("key").and_then(Value::as_str) { values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); } + // The Place queue is read later, by a click outside any automation scope: its files must + // be readable now, under the roots of whoever sets it. + if let Some(entries) = values.get("queue").and_then(|q| q.get("entries")).and_then(Value::as_array) { + for path in entries.iter().filter_map(|e| e.pointer("/params/path").and_then(Value::as_str)) { + crate::file_access::check_read(path)?; + } + } if tool.unwrap_or(self.tool_id()) == "dimension" { if let Some(style) = values.get("style") { vectorcraft_doc::dimension::DimensionOptions::from_params(style.clone())?; diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index 74ccfc0b4..80c189b3f 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -283,7 +283,7 @@ fn the_eyedropper_takes_an_image_pixel_into_the_working_rgb_space() { let l = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::translate((300.0, 0.0)) * Affine::scale(100.0); - let im = ImageObject { key: "px".into(), width: 1, height: 1, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: "px".into(), width: 1, height: 1, xf, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| vectorcraft_engine::EngineError::Other(e.to_string())) }) .unwrap(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index efb3bcf4d..1881fafc4 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -889,7 +889,7 @@ fn gradient_mesh_from_images_with_junk_params() { d.images.insert("pic".into(), ImageBlob::png(bytes.clone())); let id = d.alloc_id(); let link = linked.then(|| LinkInfo { path: "/nonexistent/pic.png".into(), ..Default::default() }); - let im = ImageObject { key: "pic".into(), width: *w, height: *h, xf: *xf, link, placement: Default::default() }; + let im = ImageObject { key: "pic".into(), width: *w, height: *h, xf: *xf, link, placement: Default::default(), pixels: None }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(()) diff --git a/crates/engine/tests/affinity_features.rs b/crates/engine/tests/it/affinity_features.rs similarity index 99% rename from crates/engine/tests/affinity_features.rs rename to crates/engine/tests/it/affinity_features.rs index dbc10fcb8..cfabffc9a 100644 --- a/crates/engine/tests/affinity_features.rs +++ b/crates/engine/tests/it/affinity_features.rs @@ -2,12 +2,9 @@ //! layer effects and embedded documents, on original synthetic documents built with the reader's //! `synth` feature (no Affinity software or files were used). The fixtures follow DesignCraft's //! Affinity import tests, whose reader VectorCraft shares. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -#[path = "support/affinity_docs.rs"] -mod affinity_docs; -#[path = "../../affinity/tests/support/text_fixture.rs"] -mod text_fixture; +use crate::affinity_docs; +use crate::text_fixture; use std::sync::Arc; diff --git a/crates/engine/tests/affinity_local.rs b/crates/engine/tests/it/affinity_local.rs similarity index 98% rename from crates/engine/tests/affinity_local.rs rename to crates/engine/tests/it/affinity_local.rs index d3589fabf..b9854c6ba 100644 --- a/crates/engine/tests/affinity_local.rs +++ b/crates/engine/tests/it/affinity_local.rs @@ -3,7 +3,6 @@ //! stored in the file. Reports the mean difference per artboard (0–255, both on white); with //! `AFFINITY_SAMPLES_OUT` set, writes each board's thumbnail crop and render side by side there. //! Skipped when `AFFINITY_SAMPLES` is unset (as in CI). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; diff --git a/crates/engine/tests/artboard_click.rs b/crates/engine/tests/it/artboard_click.rs similarity index 99% rename from crates/engine/tests/artboard_click.rs rename to crates/engine/tests/it/artboard_click.rs index ff0066154..c9a7df8d0 100644 --- a/crates/engine/tests/artboard_click.rs +++ b/crates/engine/tests/it/artboard_click.rs @@ -1,7 +1,6 @@ //! Artboard clicks fit the object without changing its art or selection, and are one undo step. //! The second click of a double-click on an object does not fit another artboard, and that //! double-click does not open Artboard Options. Empty artboard space still does. -#![allow(clippy::unwrap_used)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/clear_commands.rs b/crates/engine/tests/it/clear_commands.rs similarity index 99% rename from crates/engine/tests/clear_commands.rs rename to crates/engine/tests/it/clear_commands.rs index 1cb639e20..50a6fbd86 100644 --- a/crates/engine/tests/clear_commands.rs +++ b/crates/engine/tests/it/clear_commands.rs @@ -1,5 +1,4 @@ //! Clear deletes its exact targets, including compound members (#786). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/compound_attributes.rs b/crates/engine/tests/it/compound_attributes.rs similarity index 98% rename from crates/engine/tests/compound_attributes.rs rename to crates/engine/tests/it/compound_attributes.rs index 3366fc9c7..0bec97554 100644 --- a/crates/engine/tests/compound_attributes.rs +++ b/crates/engine/tests/it/compound_attributes.rs @@ -1,5 +1,4 @@ //! Compound appearance and fill-rule edits from a directly selected member (#1197). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/it/dimensions.rs similarity index 99% rename from crates/engine/tests/dimensions.rs rename to crates/engine/tests/it/dimensions.rs index 8413aef71..7c4872d56 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -1,5 +1,4 @@ //! Linked measurements, history, portable artwork and hostile command input. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_engine::{ Session, ViewInfo, diff --git a/crates/engine/tests/eyedropper_sources.rs b/crates/engine/tests/it/eyedropper_sources.rs similarity index 99% rename from crates/engine/tests/eyedropper_sources.rs rename to crates/engine/tests/it/eyedropper_sources.rs index 9dc0ebf25..9639f3b8e 100644 --- a/crates/engine/tests/eyedropper_sources.rs +++ b/crates/engine/tests/it/eyedropper_sources.rs @@ -1,5 +1,4 @@ //! Visible art is readable by the Eyedropper without making it editable (#1251). -#![allow(clippy::unwrap_used)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/find_replace_paras.rs b/crates/engine/tests/it/find_replace_paras.rs similarity index 90% rename from crates/engine/tests/find_replace_paras.rs rename to crates/engine/tests/it/find_replace_paras.rs index b511ddf08..05217dd49 100644 --- a/crates/engine/tests/find_replace_paras.rs +++ b/crates/engine/tests/it/find_replace_paras.rs @@ -1,7 +1,5 @@ //! `edit.findReplace` keeps per-paragraph styles lined up with the text when a find or replace //! string holds a line break, as `text.editRange` does. -// 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 serde_json::json; use vectorcraft_doc::{Justify, NodeId, NodeKind}; diff --git a/crates/engine/tests/inline_text.rs b/crates/engine/tests/it/inline_text.rs similarity index 97% rename from crates/engine/tests/inline_text.rs rename to crates/engine/tests/it/inline_text.rs index 5b009ea61..a7c23c836 100644 --- a/crates/engine/tests/inline_text.rs +++ b/crates/engine/tests/it/inline_text.rs @@ -1,7 +1,5 @@ //! Inline graphics in text drawn on the canvas and written to SVG and PDF: a symbol (a red //! circle drawn in code) set inline in a line of type. -// 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 serde_json::{Value, json}; use vectorcraft_doc::{Document, NodeId, NodeKind, TextObject}; diff --git a/crates/engine/tests/known_bugs.rs b/crates/engine/tests/it/known_bugs.rs similarity index 92% rename from crates/engine/tests/known_bugs.rs rename to crates/engine/tests/it/known_bugs.rs index 61f1bf6da..ea394663a 100644 --- a/crates/engine/tests/known_bugs.rs +++ b/crates/engine/tests/it/known_bugs.rs @@ -1,7 +1,5 @@ //! Minimal reproductions of engine bugs found by the property tests. Each is `#[ignore]`d with a //! `BUG:` reason until fixed; remove the attribute when the fix lands. -// 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 serde_json::json; use vectorcraft_engine::Session; diff --git a/crates/engine/tests/lossy_overwrite.rs b/crates/engine/tests/it/lossy_overwrite.rs similarity index 98% rename from crates/engine/tests/lossy_overwrite.rs rename to crates/engine/tests/it/lossy_overwrite.rs index c4ee60b4d..559e45a33 100644 --- a/crates/engine/tests/lossy_overwrite.rs +++ b/crates/engine/tests/it/lossy_overwrite.rs @@ -1,8 +1,6 @@ //! A file whose import left something out isn't written over silently: its hidden text, art or //! layers would be gone for good. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use serde_json::json; use vectorcraft_engine::Session; diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..b0409cffd --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,29 @@ +//! 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/affinity_docs.rs"] +mod affinity_docs; +#[path = "../../../affinity/tests/support/text_fixture.rs"] +mod text_fixture; + +mod affinity_features; +mod affinity_local; +mod artboard_click; +mod clear_commands; +mod compound_attributes; +mod dimensions; +mod eyedropper_sources; +mod find_replace_paras; +mod inline_text; +mod known_bugs; +mod lossy_overwrite; +mod model_based; +mod param_keys; +mod pen_click_after_smooth; +mod pen_endpoint_picking; +mod ruler_origin; +mod selection_commands; +mod stroke_visibility; +mod text_stroke_scale; +mod three_d_light_color; diff --git a/crates/engine/tests/model_based.rs b/crates/engine/tests/it/model_based.rs similarity index 98% rename from crates/engine/tests/model_based.rs rename to crates/engine/tests/it/model_based.rs index 13a210324..b11e3b059 100644 --- a/crates/engine/tests/model_based.rs +++ b/crates/engine/tests/it/model_based.rs @@ -1,7 +1,5 @@ //! Model-based / stateful property tests: random command sequences drawn from the registry, with //! structural invariants checked after every step and history/serialization invariants at the end. -// 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 proptest::prelude::*; use serde_json::json; diff --git a/crates/engine/tests/param_keys.rs b/crates/engine/tests/it/param_keys.rs similarity index 99% rename from crates/engine/tests/param_keys.rs rename to crates/engine/tests/it/param_keys.rs index fbde80a7a..3da393ca8 100644 --- a/crates/engine/tests/param_keys.rs +++ b/crates/engine/tests/it/param_keys.rs @@ -1,6 +1,5 @@ //! A caller's params are checked against the command's description, but a journal replays as it //! was recorded (#1442). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_engine::{Session, check_params}; diff --git a/crates/engine/tests/pen_click_after_smooth.rs b/crates/engine/tests/it/pen_click_after_smooth.rs similarity index 96% rename from crates/engine/tests/pen_click_after_smooth.rs rename to crates/engine/tests/it/pen_click_after_smooth.rs index d36a701b6..21b53a860 100644 --- a/crates/engine/tests/pen_click_after_smooth.rs +++ b/crates/engine/tests/it/pen_click_after_smooth.rs @@ -1,5 +1,4 @@ //! A click after a smooth anchor: the new corner has no handles, so the segment curves from the previous anchor's side only. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_doc::NodeKind; diff --git a/crates/engine/tests/pen_endpoint_picking.rs b/crates/engine/tests/it/pen_endpoint_picking.rs similarity index 99% rename from crates/engine/tests/pen_endpoint_picking.rs rename to crates/engine/tests/it/pen_endpoint_picking.rs index cfba10538..b2e77e52e 100644 --- a/crates/engine/tests/pen_endpoint_picking.rs +++ b/crates/engine/tests/it/pen_endpoint_picking.rs @@ -1,5 +1,4 @@ //! Original tiny-geometry controls for Pen endpoint/Alt integration with continue-any-path. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_doc::{Node, NodeId, NodeKind}; diff --git a/crates/engine/tests/ruler_origin.rs b/crates/engine/tests/it/ruler_origin.rs similarity index 99% rename from crates/engine/tests/ruler_origin.rs rename to crates/engine/tests/it/ruler_origin.rs index 353f0ccab..4b5c06286 100644 --- a/crates/engine/tests/ruler_origin.rs +++ b/crates/engine/tests/it/ruler_origin.rs @@ -1,5 +1,4 @@ //! #1212: ruler coordinates are saved metadata, independent of the artwork's coordinates. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::RulerMode; diff --git a/crates/engine/tests/selection_commands.rs b/crates/engine/tests/it/selection_commands.rs similarity index 84% rename from crates/engine/tests/selection_commands.rs rename to crates/engine/tests/it/selection_commands.rs index 94db94a7b..4e9b8e10b 100644 --- a/crates/engine/tests/selection_commands.rs +++ b/crates/engine/tests/it/selection_commands.rs @@ -1,5 +1,4 @@ //! Selection command results and validation for agent callers (#784). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::NodeId; @@ -133,3 +132,32 @@ fn select_key_and_document_inspect_report_the_key_object() { assert_eq!(s.execute("select.key", &json!({"id": b.0})).unwrap(), json!({"key": null})); assert_eq!(inspected(&mut s), Some(Value::Null)); } + +#[test] +fn select_segments_mode_toggle_and_add() { + let (mut s, a, _) = session(); + // Select segment 0 with mode set + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "set"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert_eq!(sel.segments.get(&a), Some(&[(0, 0)].into())); + assert_eq!(sel.anchors.get(&a), Some(&[(0, 0), (0, 1)].into())); + + // Toggle segment 1 on (add) + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 1]], "mode": "toggle"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert_eq!(sel.segments.get(&a), Some(&[(0, 0), (0, 1)].into())); + assert_eq!(sel.anchors.get(&a), Some(&[(0, 0), (0, 1), (0, 2)].into())); + + // Toggle segment 0 off + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "toggle"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert_eq!(sel.segments.get(&a), Some(&[(0, 1)].into())); + assert_eq!(sel.anchors.get(&a), Some(&[(0, 1), (0, 2)].into())); + + // Toggle segment 1 off -> clears selection for this path + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 1]], "mode": "toggle"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert!(!sel.contains(a)); + assert!(sel.segments.is_empty()); + assert!(sel.anchors.is_empty()); +} diff --git a/crates/engine/tests/stroke_visibility.rs b/crates/engine/tests/it/stroke_visibility.rs similarity index 99% rename from crates/engine/tests/stroke_visibility.rs rename to crates/engine/tests/it/stroke_visibility.rs index f9c574ab7..8f9ecdff0 100644 --- a/crates/engine/tests/stroke_visibility.rs +++ b/crates/engine/tests/it/stroke_visibility.rs @@ -1,5 +1,4 @@ //! Painting a previously unpainted zero-width stroke restores the existing 1 pt default. -#![allow(clippy::unwrap_used)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/text_stroke_scale.rs b/crates/engine/tests/it/text_stroke_scale.rs similarity index 97% rename from crates/engine/tests/text_stroke_scale.rs rename to crates/engine/tests/it/text_stroke_scale.rs index 10ab2bf0a..bece66d47 100644 --- a/crates/engine/tests/text_stroke_scale.rs +++ b/crates/engine/tests/it/text_stroke_scale.rs @@ -1,6 +1,5 @@ //! A stroke weight set on type is a document-space weight, whether the type was enlarged by its //! font size or by a transform (issue #3241). -#![allow(clippy::unwrap_used, clippy::panic)] use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind}; diff --git a/crates/engine/tests/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs similarity index 99% rename from crates/engine/tests/three_d_light_color.rs rename to crates/engine/tests/it/three_d_light_color.rs index f9de119f1..cb04c5649 100644 --- a/crates/engine/tests/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -1,5 +1,4 @@ //! Colored 3D lighting remains a live command and survives native and vector output. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId}; use vectorcraft_engine::Session; diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index d6af59010..6743c2a2a 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -29,6 +29,8 @@ ruzstd = { workspace = true } hayro-ccitt = { workspace = true } # Live effects' params, as the art styles that name them give them. serde_json = { workspace = true } +# Substring search for PostScript `search`. +memchr = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 4866ffad1..b5a3ee574 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -24,9 +24,42 @@ const MAX_PATCHES: usize = 4096; /// 34 × 34 points, so the patch cap alone doesn't bound them. const MAX_POINTS: usize = MAX_PATCHES * 16; +/// The largest grid `/Size` read as one mesh, a side. +const MAX_SIDE: usize = 64; +/// The most rows or columns a mesh stitched from its patches may have once the lines through the +/// anchors on its patches' edges are added. +const MAX_LINES: usize = 256; + +/// A patch as read: its place in the grid (`/P`), its corners and the anchors on its edges. +#[derive(Debug, Clone)] +struct Cell { + at: Option<(i64, i64)>, + corners: [Corner; 4], + anchors: Vec<(usize, [f64; 6])>, +} + +/// Why the patches of a mesh don't make one grid. +#[derive(Debug, PartialEq)] +pub(super) enum NotOne { + /// They don't say where they are in it (no `/Size` or `/P`, or ones that don't add up): read + /// as separate patches, as files that don't say are. + Unplaced, + /// They fill the grid but don't join up (corners or edge anchors that don't meet, or more + /// lines than are kept). + Unjoined, +} + #[derive(Debug, Default)] pub(super) struct MeshReader { space: String, + /// `/Type` (`Cartesian` in the files read). + kind: Option, + /// `/Size [a b]`: the grid's patches along the first and the second index of `/P`. + size: Option<(i64, i64)>, + /// The current patch's `/P [i j]`. + at: Option<(i64, i64)>, + /// The patches read, in order, for [`Self::one_mesh`]. + cells: Vec, corners: Vec>, /// The patch's anchors between its corners (`/A`, `/S`): the edge, and a point and its two handles. anchors: Vec<(usize, [f64; 6])>, @@ -45,6 +78,9 @@ impl MeshReader { let value = at.checked_sub(1).and_then(|i| vals.get(i)); match key.as_str() { "CS" => self.space = value.and_then(V::text).unwrap_or_default(), + "Type" => self.kind = value.and_then(V::text), + "Size" => self.size = pair(value), + "P" => self.at = pair(value), "N" => { if let Some(V::Arr(a)) = value { if self.corners.len() < 4 { @@ -70,6 +106,12 @@ impl MeshReader { "E" => { let corners = std::mem::take(&mut self.corners); let anchors = std::mem::take(&mut self.anchors); + let at = self.at.take(); + if let Some(c) = self.read_corners(&corners, to_doc) + && self.cells.len() < MAX_PATCHES + { + self.cells.push(Cell { at, corners: c, anchors: anchors.clone() }); + } match self.patch(&corners, &anchors, to_doc) { Some(m) if self.patches.len() < MAX_PATCHES && self.points + m.points.len() <= MAX_POINTS => { self.points += m.points.len(); @@ -92,7 +134,8 @@ impl MeshReader { }) } - fn patch(&self, corners: &[Vec], anchors: &[(usize, [f64; 6])], to_doc: Affine) -> Option { + /// The four corners of a patch, in the document. + fn read_corners(&self, corners: &[Vec], to_doc: Affine) -> Option<[Corner; 4]> { let [n0, n1, n2, n3] = corners else { return None }; // (anchor, towards the next corner, towards the previous one, colour, opacity) let read = |n: &Vec| -> Option<(Point, Vec2, Vec2, Color, f32)> { @@ -121,9 +164,13 @@ impl MeshReader { let color = self.color(n.get(..comps)?)?; Some((at, to_doc * Point::new(nx, ny) - at, to_doc * Point::new(px, py) - at, color, opacity.clamp(0.0, 1.0) as f32)) }; - let (a, b, c, d) = (read(n0)?, read(n1)?, read(n2)?, read(n3)?); + Some([read(n0)?, read(n1)?, read(n2)?, read(n3)?]) + } + + fn patch(&self, corners: &[Vec], anchors: &[(usize, [f64; 6])], to_doc: Affine) -> Option { + let [a, b, c, d] = self.read_corners(corners, to_doc)?; if !anchors.is_empty() { - return Some(coons([a, b, c, d], anchors)); + return Some(coons([a, b, c, d], anchors, None)); } // Rows run from the first corner to the second, columns from the first to the fourth. let point = |(p, _, _, color, opacity): (Point, Vec2, Vec2, Color, f32), handles: [(usize, Vec2); 2]| { @@ -145,13 +192,150 @@ impl MeshReader { } } +impl MeshReader { + /// The mesh as one grid of shared points: each patch placed by its `/P` in a grid of `/Size`, + /// the corners neighbours share joined, and where a patch's edge bends through anchors, a mesh + /// line through the whole row or column there (every patch in it cut at the same place), so + /// each cell's edges stay single cubics. In each patch, rows run from its first corner to its + /// second and columns from its first to its fourth; which index of `/P` is which is taken from + /// the one under which the shared corners meet. + pub fn one_mesh(&self) -> Result { + if self.unread || self.kind.as_deref().is_some_and(|k| k != "Cartesian") { + return Err(NotOne::Unplaced); + } + let Some((a, b)) = self.size else { return Err(NotOne::Unplaced) }; + let side = |n: i64| usize::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)); + let (Some(a), Some(b)) = (side(a), side(b)) else { return Err(NotOne::Unplaced) }; + if self.cells.len() != a * b { + return Err(NotOne::Unplaced); + } + // Each patch's place, once each. + let mut seen = vec![false; a * b]; + let mut places = Vec::with_capacity(self.cells.len()); + for cell in &self.cells { + let Some((i, j)) = cell.at else { return Err(NotOne::Unplaced) }; + let (Ok(i), Ok(j)) = (usize::try_from(i), usize::try_from(j)) else { return Err(NotOne::Unplaced) }; + if i >= a || j >= b { + return Err(NotOne::Unplaced); + } + let Some(slot) = seen.get_mut(i * b + j) else { return Err(NotOne::Unplaced) }; + if std::mem::replace(slot, true) { + return Err(NotOne::Unplaced); + } + places.push((i, j)); + } + // The first index along the columns (`/P [i j]` at row j, column i), or along the rows. + for across in [true, false] { + let (rows, cols) = if across { (b, a) } else { (a, b) }; + let at: Vec<(usize, usize)> = places.iter().map(|&(i, j)| if across { (j, i) } else { (i, j) }).collect(); + if let Some(mesh) = stitch(&self.cells, &at, rows, cols) { + return Ok(mesh); + } + } + Err(NotOne::Unjoined) + } +} + +/// The cuts (0 to 1) of a patch's columns and rows: where the anchors on its edges along each +/// direction cut it, the pieces of an edge taking equal shares (as [`coons`] lays them out). +fn own_cuts(cell: &Cell) -> (Vec, Vec) { + let pieces = |e: usize| cell.anchors.iter().filter(|(edge, ..)| *edge == e).count() + 1; + let cuts = |n: usize| (0..=n).map(|i| i as f64 / n as f64).collect::>(); + // Columns along the second and fourth edges (u), rows along the first and third (v). + let merge = |x: Vec, y: Vec| union(&x, &y); + (merge(cuts(pieces(1)), cuts(pieces(3))), merge(cuts(pieces(0)), cuts(pieces(2)))) +} + +/// The sorted union of two lists of cuts. +fn union(a: &[f64], b: &[f64]) -> Vec { + let mut c: Vec = a.iter().chain(b).copied().filter(|v| v.is_finite()).collect(); + c.sort_by(f64::total_cmp); + c.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + c +} + +/// The patches `cells`, each at `at` (row, column) of a `rows` × `cols` grid, as one mesh; `None` +/// when their shared points don't meet, a value isn't finite, or the mesh would be too large. +fn stitch(cells: &[Cell], at: &[(usize, usize)], rows: usize, cols: usize) -> Option { + // The cuts each row and column of patches takes: those of every patch in it. + let (mut col_cuts, mut row_cuts) = (vec![vec![0.0, 1.0]; cols], vec![vec![0.0, 1.0]; rows]); + for (cell, &(r, c)) in cells.iter().zip(at) { + let (us, vs) = own_cuts(cell); + let cc = col_cuts.get_mut(c)?; + *cc = union(cc, &us); + let rc = row_cuts.get_mut(r)?; + *rc = union(rc, &vs); + } + // Where each row and column of patches starts among the mesh's lines. + let starts = |cuts: &[Vec]| -> Vec { + cuts.iter() + .scan(0usize, |n, c| { + let s = *n; + *n += c.len().saturating_sub(1); + Some(s) + }) + .collect() + }; + let (col_at, row_at) = (starts(&col_cuts), starts(&row_cuts)); + let total = |cuts: &[Vec]| cuts.iter().map(|c| c.len().saturating_sub(1)).sum::(); + let (mesh_cols, mesh_rows) = (total(&col_cuts), total(&row_cuts)); + if mesh_cols == 0 || mesh_rows == 0 || mesh_cols > MAX_LINES || mesh_rows > MAX_LINES || (mesh_rows + 1) * (mesh_cols + 1) > MAX_POINTS { + return None; + } + let width = mesh_cols + 1; + let mut points: Vec> = vec![None; (mesh_rows + 1) * width]; + for (cell, &(r, c)) in cells.iter().zip(at) { + let (us, vs) = (col_cuts.get(c)?, row_cuts.get(r)?); + let part = coons(cell.corners, &cell.anchors, Some((us, vs))); + let part_width = part.cols as usize + 1; + for (k, q) in part.points.into_iter().enumerate() { + let (pr, pc) = (k / part_width, k % part_width); + let slot = points.get_mut((row_at.get(r)? + pr) * width + col_at.get(c)? + pc)?; + match slot { + None => *slot = Some(q), + Some(had) => { + // A point two patches share: where both have it. + if (had.p - q.p).hypot() > 1e-6 * (1.0 + had.p.to_vec2().hypot()) { + return None; + } + // Its handles towards each side come from the patch on that side. + for (h, n) in had.handles.iter_mut().zip(q.handles) { + if h.hypot() == 0.0 { + *h = n; + } + } + } + } + } + } + let points: Vec = points.into_iter().collect::>()?; + let finite = |v: f64| v.is_finite(); + if !points.iter().all(|q| finite(q.p.x) && finite(q.p.y) && q.opacity.is_finite() && q.handles.iter().all(|h| finite(h.x) && finite(h.y))) { + return None; + } + Some(GradientMesh { rows: mesh_rows as u32, cols: mesh_cols as u32, points }) +} + +/// A two-number value (`[a b]`) as whole numbers. +fn pair(v: Option<&V>) -> Option<(i64, i64)> { + let V::Arr(a) = v? else { return None }; + let n: Vec = a.iter().filter_map(V::num).collect(); + match n.as_slice() { + &[x, y] if x.fract() == 0.0 && y.fract() == 0.0 && x.abs() < 1e6 && y.abs() < 1e6 => Some((x as i64, y as i64)), + _ => None, + } +} + type Corner = (Point, Vec2, Vec2, Color, f32); type Cubic = [Point; 4]; /// A patch whose edges bend through more anchors: its edges (first corner → second → third → fourth /// → first) as runs of cubics, each anchor on the edge it lies along, and the Coons patch they bound /// as a mesh cut wherever an edge has an anchor, so each cell's edges are single cubics again. -fn coons(corners: [Corner; 4], anchors: &[(usize, [f64; 6])]) -> GradientMesh { +/// +/// `cuts`: the columns and rows (0 to 1) to cut it at instead of its own, which they include: those +/// of the mesh's whole column and row of patches ([`stitch`]). +fn coons(corners: [Corner; 4], anchors: &[(usize, [f64; 6])], cuts_at: Option<(&[f64], &[f64])>) -> GradientMesh { let runs: Vec> = (0..4) .map(|e| { let (p, q) = (&corners[e], &corners[(e + 1) % 4]); @@ -202,7 +386,10 @@ fn coons(corners: [Corner; 4], anchors: &[(usize, [f64; 6])]) -> GradientMesh { c.dedup_by(|x, y| (*x - *y).abs() < 1e-9); c }; - let (us, vs) = (cuts(&top, &bottom), cuts(&left, &right)); + let (us, vs) = match cuts_at { + Some((us, vs)) => (us.to_vec(), vs.to_vec()), + None => (cuts(&top, &bottom), cuts(&left, &right)), + }; let (ra, rb, rc, rd) = (&corners[0], &corners[1], &corners[2], &corners[3]); let mut points = Vec::with_capacity(us.len() * vs.len()); for (r, &v) in vs.iter().enumerate() { @@ -376,4 +563,167 @@ mod tests { } assert!(reader.unread && reader.corners.len() == 4); } + + /// Hand-written grid of grey patches: `/Type`, `/Size [cols rows]` and each patch at `/P [x y]` + /// (the first index along x, as the files read write it), its corners from the one nearest the + /// origin up, across and down (`a b c d`), straight edges with handles a third of the way, the + /// grey of a point `at(x, y)`. `moved` puts the points listed elsewhere; `anchor` adds an anchor + /// to the edges at x = 10 (between the first two columns), at height 5 and 1 to the right. + struct Grid { + cols: usize, + rows: usize, + moved: Vec<((f64, f64), (f64, f64))>, + anchor: bool, + place: bool, + } + + impl Grid { + fn new(cols: usize, rows: usize) -> Self { + Grid { cols, rows, moved: vec![], anchor: false, place: true } + } + fn at(&self, x: f64, y: f64) -> (f64, f64) { + self.moved.iter().find(|(from, _)| *from == (x, y)).map_or((x, y), |(_, to)| *to) + } + fn read(&self, size: [f64; 2]) -> MeshReader { + let mut r = MeshReader::default(); + let name = |n: &str| V::Name(n.into()); + let arr = |v: &[f64]| V::Arr(v.iter().map(|x| V::Num(*x)).collect()); + r.entry(&[name("DeviceGray"), name("CS")], Affine::IDENTITY); + r.entry(&[name("Cartesian"), name("Type")], Affine::IDENTITY); + r.entry(&[arr(&size), name("Size")], Affine::IDENTITY); + for i in 0..self.cols { + for j in 0..self.rows { + let (x0, y0, x1, y1) = (i as f64 * 10.0, j as f64 * 10.0, (i + 1) as f64 * 10.0, (j + 1) as f64 * 10.0); + if self.place { + r.entry(&[arr(&[i as f64, j as f64]), name("P")], Affine::IDENTITY); + } + let third = |p: (f64, f64), q: (f64, f64)| (p.0 + (q.0 - p.0) / 3.0, p.1 + (q.1 - p.1) / 3.0); + let (a, b, c, d) = (self.at(x0, y0), self.at(x0, y1), self.at(x1, y1), self.at(x1, y0)); + let grey = |p: (f64, f64)| (p.0 + p.1) / 40.0; + for (k, (p, next, prev)) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)].into_iter().enumerate() { + let (n, q) = (third(p, next), third(p, prev)); + r.entry(&[arr(&[grey(p), 1.0, p.0, p.1, n.0, n.1, 1.0, q.0, q.1, 1.0]), name("N")], Affine::IDENTITY); + // The edge at x = 10: the third edge (c → d) of the first column, the first + // (a → b) of the second; listed upwards either way. + let on_shared = (k == 2 && i == 0) || (k == 0 && i == 1); + if self.anchor && on_shared { + r.entry(&[arr(&[11.0, 5.0, 11.0, 3.0, 11.0, 7.0, 1.0]), name("A")], Affine::IDENTITY); + } + } + r.entry(&[V::Num(0.0), name("E")], Affine::IDENTITY); + } + } + r + } + } + + /// A 2 × 2 mesh comes in as one mesh of 9 shared points, each where the patches put it, with + /// the colours the separate patches had at every place. + #[test] + fn a_grid_of_patches_is_one_mesh() { + let g = Grid::new(2, 2); + let r = g.read([2.0, 2.0]); + let mesh = r.one_mesh().unwrap(); + assert_eq!((mesh.rows, mesh.cols, mesh.points.len()), (2, 2, 9)); + for row in 0..=2 { + for col in 0..=2 { + let p = mesh.points[mesh.idx(row, col)].p; + assert!((p - Point::new(col as f64 * 10.0, row as f64 * 10.0)).hypot() < 1e-9, "({row}, {col}) at {p:?}"); + } + } + // The colour across each cell is that of the patch it was (patches are read x first). + for (k, patch) in r.patches.iter().enumerate() { + let (col, row) = (k / 2, k % 2); + for (u, v) in [(0.25, 0.25), (0.5, 0.5), (0.9, 0.1)] { + let (one, _) = mesh.color_at(row, col, u, v); + let (own, _) = patch.color_at(0, 0, u, v); + assert!( + one.to_rgb().iter().zip(own.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), + "cell ({row}, {col}) at {u} {v}: {one:?} vs {own:?}" + ); + } + } + // Inner handles point at the neighbours, a third of the way. + let inner = &mesh.points[mesh.idx(1, 1)]; + assert!((inner.handles[H_RIGHT] - Vec2::new(10.0 / 3.0, 0.0)).hypot() < 1e-9); + assert!((inner.handles[H_UP] - Vec2::new(0.0, -10.0 / 3.0)).hypot() < 1e-9); + } + + /// A mesh point moved inside the grid is one point of the mesh, which the four cells round it + /// share: moving it moves them all, leaving no hole. + #[test] + fn a_moved_inner_point_stays_shared() { + let mut g = Grid::new(2, 2); + g.moved = vec![((10.0, 10.0), (13.0, 8.0))]; + let mut mesh = g.read([2.0, 2.0]).one_mesh().unwrap(); + let k = mesh.idx(1, 1); + assert!((mesh.points[k].p - Point::new(13.0, 8.0)).hypot() < 1e-9); + mesh.points[k].p = Point::new(15.0, 15.0); + for (r, c) in [(0, 0), (0, 1), (1, 0), (1, 1)] { + let corner = mesh.eval(r, c, 1.0 - c as f64, 1.0 - r as f64); + assert!((corner - Point::new(15.0, 15.0)).hypot() < 1e-9, "cell ({r}, {c}): {corner:?}"); + } + } + + /// An anchor on the edge two patches share runs a mesh line through the whole mesh there: one + /// mesh, three points across that edge, the anchor's the middle one, its handles along the edge. + #[test] + fn an_anchor_on_a_shared_edge_adds_a_line_through_the_mesh() { + let mut g = Grid::new(2, 1); + g.anchor = true; + let r = g.read([2.0, 1.0]); + let mesh = r.one_mesh().unwrap(); + assert_eq!((mesh.rows, mesh.cols), (2, 2), "split at the anchor's height"); + let mid = &mesh.points[mesh.idx(1, 1)]; + assert!((mid.p - Point::new(11.0, 5.0)).hypot() < 1e-9, "{:?}", mid.p); + assert!( + (mid.handles[H_UP] - Vec2::new(0.0, -2.0)).hypot() < 1e-9 && (mid.handles[H_DOWN] - Vec2::new(0.0, 2.0)).hypot() < 1e-9, + "{:?}", + mid.handles + ); + // The patch without an anchor of its own is cut on the same line, on its straight edge. + let left = mesh.points[mesh.idx(1, 0)].p; + assert!((left - Point::new(0.0, 5.0)).hypot() < 1e-9, "{left:?}"); + } + + /// A patch whose first corner and the anchors on its edges sit on one point (no handles) + /// comes in, every value a number. + #[test] + fn a_collapsed_patch_comes_in_with_finite_values() { + let mut r = MeshReader::default(); + let name = |n: &str| V::Name(n.into()); + let arr = |v: &[f64]| V::Arr(v.iter().map(|x| V::Num(*x)).collect()); + r.entry(&[name("DeviceGray"), name("CS")], Affine::IDENTITY); + r.entry(&[arr(&[1.0, 1.0]), name("Size")], Affine::IDENTITY); + r.entry(&[arr(&[0.0, 0.0]), name("P")], Affine::IDENTITY); + // An anchor on the edge back to the first corner, before it; then the corner on that point. + r.entry(&[arr(&[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 1.0]), name("A")], Affine::IDENTITY); + r.entry(&[arr(&[0.5, 1.0, 5.0, 5.0, 5.0, 5.0, 1.0, 5.0, 5.0, 1.0]), name("N")], Affine::IDENTITY); + r.entry(&[arr(&[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 1.0]), name("A")], Affine::IDENTITY); + for (x, y) in [(5.0, 10.0), (10.0, 10.0), (10.0, 5.0)] { + r.entry(&[arr(&[0.2, 1.0, x, y, x, y, 1.0, x, y, 1.0]), name("N")], Affine::IDENTITY); + } + r.entry(&[V::Num(0.0), name("E")], Affine::IDENTITY); + let mesh = r.one_mesh().unwrap(); + assert!(mesh.points.iter().all(|q| q.p.x.is_finite() && q.p.y.is_finite() && q.handles.iter().all(|h| h.x.is_finite() && h.y.is_finite()))); + } + + /// Patches that don't say where they are, or not as many as the grid has, are read as before: + /// each a mesh of its own. Ones that fill the grid but don't meet say so. + #[test] + fn unplaced_or_unjoined_patches_stay_separate() { + let mut g = Grid::new(2, 2); + g.place = false; + assert_eq!(g.read([2.0, 2.0]).one_mesh().unwrap_err(), NotOne::Unplaced); + let g = Grid::new(2, 2); + assert_eq!(g.read([3.0, 2.0]).one_mesh().unwrap_err(), NotOne::Unplaced); + assert_eq!(g.read([f64::NAN, 2.0]).one_mesh().unwrap_err(), NotOne::Unplaced); + assert_eq!(g.read([2.0, 2.0]).patches.len(), 4, "the separate patches are still read"); + // A corner that only one of the patches sharing it moved. + let mut r = Grid::new(2, 1).read([2.0, 1.0]); + if let Some(c) = r.cells.get_mut(1) { + c.corners[0].0 = Point::new(10.0, 1.0); + } + assert_eq!(r.one_mesh().unwrap_err(), NotOne::Unjoined); + } } diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index 901c37085..eb0785553 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -31,7 +31,7 @@ use super::lex::find; use crate::ps; pub(crate) use frames::text_frames; -pub use read::{Structure, artboards, read, variables}; +pub use read::{IMAGE_TOO_LARGE, Structure, artboards, read, variables}; pub(crate) use read::{slot_frame, slot_of}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 991b74cf1..99b0eac77 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -76,6 +76,9 @@ const MAX_NODES: usize = 4_000_000; const MAX_POINTS: usize = 4_000_000; /// Largest image read (pixels). const MAX_PIXELS: u64 = 1 << 25; +/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads +/// larger images (#1479). +pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; /// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; /// Largest side of the document and its artboards (points). @@ -2009,6 +2012,18 @@ impl<'a> Reader<'a> { return Ok(()); } let gs = self.gs.clone(); + // One mesh where the patches join up into the grid they say they make; else each patch is + // a mesh of its own, in a group. + match m.one_mesh() { + Ok(one) => { + let mut node = self.new_node(NodeKind::Mesh(one), &gs); + node.opacity = gs.opacity; + node.blend = gs.blend; + return self.add(node, hidden); + } + Err(super::mesh::NotOne::Unjoined) => self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches"), + Err(super::mesh::NotOne::Unplaced) => {} + } let mut patches: Vec = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect(); let mut node = match patches.len() { 1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)), @@ -2084,7 +2099,11 @@ impl<'a> Reader<'a> { return Ok(()); }; let (w, h) = (*w as u64, *h as u64); - if w == 0 || h == 0 || w.saturating_mul(h) > MAX_PIXELS || ![1.0, 8.0].contains(bits) { + if w.saturating_mul(h) > MAX_PIXELS { + self.warn(IMAGE_TOO_LARGE); + return Ok(()); + } + if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { self.warn("an image VectorCraft can't read was left out"); return Ok(()); } @@ -2180,7 +2199,7 @@ impl<'a> Reader<'a> { let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); - self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }, mask, false) + self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false) } /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its @@ -2196,7 +2215,7 @@ impl<'a> Reader<'a> { self.doc.images.entry(key.clone()).or_insert(blob); let art = Node::new( self.doc.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), ); Some(OpacityMask::new(art, true)) } @@ -2241,7 +2260,10 @@ impl<'a> Reader<'a> { let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); self.warn(LINKED_PREVIEWS); - self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default() }, hidden) + self.add_image( + ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }, + hidden, + ) } // ---- the document ---------------------------------------------------------------------- @@ -2329,6 +2351,8 @@ impl<'a> Reader<'a> { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }) .collect(); @@ -2341,6 +2365,8 @@ impl<'a> Reader<'a> { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }]; } diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index e666e8321..4db791972 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -731,6 +731,12 @@ impl Interp<'_> { self.push_interval(Obj::Str(s), 0, got.len()) } + /// Count `n` decoded bytes against the memory budget, and the time to decode them. + fn charge_decoded(&mut self, n: usize) -> Res { + self.alloc(n)?; + self.spend((n / 4096) as u64) + } + /// `filter`: a file that decodes its source. Over the program's own data it decodes when /// first read (the data follows the operator that reads it); over other data, at once. fn filter(&mut self) -> Res { @@ -775,6 +781,7 @@ impl Interp<'_> { let mut b = f.borrow_mut(); let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; let (out, used, rgb) = decode(&spec, data.get(*pos..).unwrap_or_default())?; + self.charge_decoded(out.len())?; *pos = pos.saturating_add(used); Stream::Buf { data: out, pos: 0, rgb } } @@ -783,11 +790,13 @@ impl Interp<'_> { Obj::Str(s) => { self.alloc(s.borrow().len())?; let (data, _, rgb) = decode(&spec, &s.borrow())?; + self.charge_decoded(data.len())?; Stream::Buf { data, pos: 0, rgb } } p @ Obj::Array { .. } => { let raw = self.gather(&p, MAX_DATA)?; let (data, _, rgb) = decode(&spec, &raw)?; + self.charge_decoded(data.len())?; Stream::Buf { data, pos: 0, rgb } } _ => return ps_err("typecheck", "filter"), @@ -830,13 +839,13 @@ impl Interp<'_> { None => { let raw = self.lex.data(); let (data, used, rgb) = decode(spec, raw)?; - self.alloc(data.len())?; + self.charge_decoded(data.len())?; self.lex.advance(used); (data, rgb) } Some((prev, was)) => { let (data, _, rgb) = decode(spec, &prev)?; - self.alloc(data.len())?; + self.charge_decoded(data.len())?; (data, rgb.or(was)) } }); @@ -1084,6 +1093,11 @@ impl Interp<'_> { let row = row_bits.div_ceil(8); // Several sources are read in turn, a row at a time each (zeros past their ends). let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? }; + if sources.len() > n { + return ps_err("rangecheck", "DataSource"); + } + let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; + self.alloc(plane.saturating_mul(sources.len()))?; let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; for p in &mut planes { p.resize(row * s.h, 0); @@ -1211,7 +1225,7 @@ impl Interp<'_> { fn add_image(&mut self, blob: ImageBlob, w: u32, h: u32, xf: Affine, mask: Option) -> Res { let key = blob.content_key(); self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }; + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; let clips = self.g.clips.clone(); self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))).with_mask(mask.map(Box::new)), &clips); Ok(()) @@ -1228,7 +1242,7 @@ impl Interp<'_> { let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }; + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; Some(OpacityMask::new(Node::new(self.out.doc.alloc_id(), NodeKind::Image(Box::new(im))), true)) } } diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 613ed3afd..5d4f7b9fa 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -15,7 +15,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, Lin use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; use super::interp::{Interp, MAX_GSAVE, matrix_obj}; -use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; +use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, clamp_between, ps_err}; /// Most objects one file makes. const MAX_NODES: usize = 1 << 20; @@ -1192,7 +1192,7 @@ impl Interp<'_> { let domain = nums(get("Domain")); let t = x.first().copied().unwrap_or(0.0); let t = match domain.as_slice() { - [a, b, ..] => t.clamp(a.min(*b), a.max(*b)), + [a, b, ..] => clamp_between(t, *a, *b), _ => t, }; match get("FunctionType").and_then(|o| o.as_num()) { @@ -1330,11 +1330,13 @@ impl Interp<'_> { } GStateNew => { self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; self.push(Obj::GState(Rc::new(self.g.clone())))?; } CurrentGState => { self.pop()?; self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; self.push(Obj::GState(Rc::new(self.g.clone())))?; } SetGState => match self.pop()? { diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index b60aac39d..fc8c9de37 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -567,11 +567,15 @@ impl<'a> Interp<'a> { None => { let (x, y) = (a.as_num().ok_or(PsError::Ps("typecheck", String::new()))?, b.as_num().ok_or(PsError::Ps("typecheck", String::new()))?); - Obj::Real(match op { + let r = match op { Add => x + y, Sub => x - y, _ => x * y, - }) + }; + if !r.is_finite() { + return ps_err("undefinedresult", ""); + } + Obj::Real(r) } }; self.push(r)?; @@ -947,6 +951,7 @@ impl<'a> Interp<'a> { let seek = self.pop_str()?.to_vec(); let s = self.pop_str()?; let text = s.to_vec(); + self.spend((text.len() / 64) as u64)?; let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) }; match at { Some(i) => { diff --git a/crates/eps/src/import/lex.rs b/crates/eps/src/import/lex.rs index 1d4918393..94f2b541b 100644 --- a/crates/eps/src/import/lex.rs +++ b/crates/eps/src/import/lex.rs @@ -252,7 +252,7 @@ pub(crate) fn find(hay: &[u8], needle: &[u8]) -> Option { if needle.is_empty() { return Some(0); } - hay.windows(needle.len()).position(|w| w == needle) + memchr::memmem::find(hay, needle) } /// Hexadecimal digits (whitespace ignored, an odd last digit padded with 0); `None` on others. diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 50d7fd6a2..99164aaa8 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -1401,20 +1401,27 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl if !has_layers(&data) { return (visible, warnings, false); } + // The PDF part, its pages placed and named as the editing data's artboards are, when the + // layers can't be used (`why`). + let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { + place_pages(&ai::artboards(&data), &mut visible, unit); + let mut all = unread(warnings, why); + all.extend(ai::variables(&data).note()); + (visible, all, false) + }; match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { + // An image too large for the editing data's reader would be missing from the layers, while + // the PDF part has it: the file comes in as its PDF part instead (#1479). + Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { + from_pdf_part(visible, warnings, "an image is too large to read from them") + } Ok((mut done, notes)) => { done.setup.bleed = visible.setup.bleed; let mut all = warnings; all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::>()); (done, all, true) } - Err(why) => { - let mut visible = visible; - place_pages(&ai::artboards(&data), &mut visible, unit); - let mut all = unread(warnings, &why); - all.extend(ai::variables(&data).note()); - (visible, all, false) - } + Err(why) => from_pdf_part(visible, warnings, &why), } } @@ -1634,6 +1641,7 @@ mod tests { xf: Affine::scale(1.0 / 1.6), link: None, placement: Default::default(), + pixels: None, })), ); page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap(); diff --git a/crates/eps/src/import/obj.rs b/crates/eps/src/import/obj.rs index f540500c4..b4dc34364 100644 --- a/crates/eps/src/import/obj.rs +++ b/crates/eps/src/import/obj.rs @@ -231,11 +231,11 @@ impl Obj { Self::Dict(DictRef::new(d)) } - /// The number this is (integers and reals). + /// The number this is (integers and finite reals: a NaN or infinite real is not a number). pub fn as_num(&self) -> Option { match *self { Self::Int(i) => Some(i as f64), - Self::Real(r) => Some(r), + Self::Real(r) if r.is_finite() => Some(r), _ => None, } } @@ -406,6 +406,13 @@ pub(crate) enum PsError { pub(crate) type Res = Result; +/// `v` limited to the range between `a` and `b` (in either order). Unlike `f64::clamp` this never +/// panics: when one bound is NaN the range collapses to the other bound (both NaN gives `v`), and a +/// NaN `v` gives the lower bound. +pub(crate) fn clamp_between(v: f64, a: f64, b: f64) -> f64 { + v.max(a.min(b)).min(a.max(b)) +} + /// A PostScript error raised at `at`. pub(crate) fn ps_err(name: &'static str, at: &str) -> Res { Err(PsError::Ps(name, at.to_string())) diff --git a/crates/eps/src/import/preview.rs b/crates/eps/src/import/preview.rs index 046043c30..0e1c09750 100644 --- a/crates/eps/src/import/preview.rs +++ b/crates/eps/src/import/preview.rs @@ -48,8 +48,10 @@ fn image_doc(img: image::RgbaImage, frame: Rect) -> Option { let key = blob.content_key(); doc.images.insert(key.clone(), blob); let xf = Affine::scale_non_uniform(frame.width() / f64::from(w), frame.height() / f64::from(h)); - let mut n = - Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); + let mut n = Node::new( + doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ); n.name = Some("Preview".into()); let layer = doc.layers.first().map(|l| l.id)?; doc.insert(Some(layer), 0, n).ok()?; diff --git a/crates/eps/src/import/shading.rs b/crates/eps/src/import/shading.rs index 2e2a523ad..c59881a98 100644 --- a/crates/eps/src/import/shading.rs +++ b/crates/eps/src/import/shading.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::{Affine, Point}; use super::graphics::Space; use super::interp::{Interp, matrix_of}; -use super::obj::{DictRef, Key, Obj, PsError, Res, ps_err}; +use super::obj::{DictRef, Key, Obj, PsError, Res, clamp_between, ps_err}; /// Most patches (or triangles) one shading makes: past it, it is left out with a warning. const MAX_PATCHES: usize = 4096; @@ -348,7 +348,7 @@ impl Interp<'_> { decode.get(2 * j + 1).or(range.get(2 * j + 1)).copied().unwrap_or(1.0), ); let (r0, r1) = (range.get(2 * j).copied().unwrap_or(0.0), range.get(2 * j + 1).copied().unwrap_or(1.0)); - (d0 + raw * (d1 - d0) / max).clamp(r0.min(r1), r0.max(r1)) + clamp_between(d0 + raw * (d1 - d0) / max, r0, r1) }; // Each input into sample space. let mut pos = Vec::with_capacity(m); @@ -356,9 +356,9 @@ impl Interp<'_> { let (a, b) = (domain.get(2 * i).copied().unwrap_or(0.0), domain.get(2 * i + 1).copied().unwrap_or(1.0)); let s = size.get(i).copied().unwrap_or(1.0); let (e0, e1) = (encode.get(2 * i).copied().unwrap_or(0.0), encode.get(2 * i + 1).copied().unwrap_or(s - 1.0)); - let xi = x.get(i).copied().unwrap_or(a).clamp(a.min(b), a.max(b)); + let xi = clamp_between(x.get(i).copied().unwrap_or(a), a, b); let e = if b != a { e0 + (xi - a) * (e1 - e0) / (b - a) } else { e0 }; - pos.push(e.clamp(0.0, s - 1.0)); + pos.push(clamp_between(e, 0.0, s - 1.0)); } if let ([p], Some(s)) = (pos.as_slice(), size.first()) { let (lo, hi) = (p.floor() as usize, (p.ceil() as usize).min(*s as usize - 1)); diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 5ace00ac2..7a503a905 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -324,6 +324,36 @@ fn shadings_become_gradients() { assert!(near(stops[1].1.to_rgb(), blue.to_rgb()), "{stops:?}"); } +/// #3585: arithmetic that overflows to infinity or NaN is an `undefinedresult` error, so a +/// non-finite number never reaches a shading function's `Domain` (where it used to panic). +#[test] +fn non_finite_arithmetic_is_undefinedresult() { + let errs = |body: &str| { + let r = read(&format!("0 0 10 10 rectfill {body}")); + assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings); + r.warnings.iter().any(|w| w.contains("undefinedresult")) + }; + for body in [ + "1.7e308 1.7e308 add pop", + "-1.7e308 1.7e308 sub pop", + "1e300 1e300 mul pop", + "-1 ln pop", + "0 ln pop", + "-1 log pop", + "-1 sqrt pop", + "10 400 exp pop", + ] { + assert!(errs(body), "{body}"); + } + let axial = + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [n n] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>"; + assert!(errs(&format!("/n 1e300 1e300 mul dup sub def 0 0 100 100 rectclip {axial} shfill"))); + // A sampled function with a Size below 1 is a rangecheck, not a panic. + let sampled = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 0 /Domain [0 1] /Range [0 1 0 1 0 1] /Size [0.5] /BitsPerSample 8 /DataSource (abc) >> >>"; + let r = read(&format!("0 0 10 10 rectfill gsave 0 0 100 100 rectclip {sampled} shfill grestore")); + assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings); +} + /// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a /// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds. /// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made @@ -568,6 +598,7 @@ fn rich_doc() -> Document { xf: Affine::new([10.0, 0.0, 0.0, 10.0, 110.0, 110.0]), link: None, placement: Default::default(), + pixels: None, }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); add(&mut d, n); @@ -835,16 +866,24 @@ fn hostile_executable_files_and_type_loops_are_refused() { /// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes /// more than 20 s. fn read_soon(body: &str) -> crate::Imported { + read_within(body, 20) +} + +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes +/// more than `secs` seconds. +fn read_within(body: &str, secs: u64) -> crate::Imported { let (tx, rx) = std::sync::mpsc::channel(); let ps = eps(body); std::thread::spawn(move || tx.send(import(&ps)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() + rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() } -/// Reads `body` after a square through [`read_soon`]: the square stays, and the program runs out -/// of the operation budget. +/// Reads `body` after a square: the square stays, and the program runs out of the operation +/// budget. Running to the end of the budget can take over 20 s on a slow CI runner +/// (`search_in_a_loop_runs_out_of_the_budget` searches about 3 GB, 14 s on a fast desktop), so the +/// limit here only catches a program that never stops. fn runs_out_of_budget(body: &str) { - let r = read_soon(&format!("0 0 10 10 rectfill {body}")); + let r = read_within(&format!("0 0 10 10 rectfill {body}"), 120); assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings); } @@ -869,7 +908,7 @@ fn paths_made_in_a_loop_run_out_of_the_budget() { /// A loop of operators that read or copy the whole current path or clip path runs out of the /// operation budget (PLRM 3rd ed., chapter 8): `pathbbox`, `clip`, `clippath`, `shfill` (which /// reads the clip path for the area it paints), `pathforall` (which copies the path before it runs -/// a procedure), `gstate`, `currentgstate` and `setgstate`. +/// a procedure) and `setgstate`. `gstate` and `currentgstate` run out of memory first. #[test] fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { // 10,001 elements reaching 10,000,000 points to the right: what `shfill` paints in a clip of @@ -883,8 +922,6 @@ fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { "clip newpath { clippath } loop", "clip newpath { sh shfill } loop", "{ { pop pop exit } { } { } { } pathforall } loop", - "{ gstate pop } loop", - "/g gstate def { g currentgstate pop } loop", "/g gstate def { g setgstate } loop", ] { runs_out_of_budget(&format!("{sh} {path} {body}")); @@ -1092,3 +1129,54 @@ fn a_damaged_palette_preview_is_refused_without_panicking() { assert!(e.contains("can't be read"), "{e}"); } } + +/// #3582: `search` in a loop on multi-megabyte strings, with a needle half as +/// long as the string that differs in its last byte, finish quickly and run out of the operation +/// budget. +#[test] +fn search_in_a_loop_runs_out_of_the_budget() { + let make = "/h 4194304 string def /n 2097152 string def n 2097152 1 sub 1 put"; + runs_out_of_budget(&format!("{make} {{ h n search {{ pop pop pop }} {{ pop }} ifelse }} loop")); +} + +/// #3568: an image dictionary with more data sources than colour components is a `rangecheck`. +#[test] +fn an_image_with_more_data_sources_than_components_is_refused() { + let sources = "<80> ".repeat(64); + let e = import(&eps(&format!( + "/DeviceGray setcolorspace << /ImageType 1 /Width 64 /Height 64 /BitsPerComponent 8 /MultipleDataSources true \ + /ImageMatrix [64 0 0 64 0 0] /DataSource [{sources}] >> image" + ))) + .unwrap_err(); + assert!(e.contains("rangecheck") && e.contains("DataSource"), "{e}"); +} + +/// #3568: every data source's plane is charged against the memory budget, not only the RGBA +/// output: two large four-source images together go over it. +#[test] +fn the_planes_of_multi_source_images_are_charged() { + let image = "/DeviceCMYK setcolorspace << /ImageType 1 /Width 8192 /Height 8192 /BitsPerComponent 8 \ + /MultipleDataSources true /ImageMatrix [8192 0 0 8192 0 0] /DataSource [<80> <80> <80> <80>] >> image\n"; + let r = import(&eps(&image.repeat(2))).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + +/// #3568: a single sample plane over the data limit is refused. +#[test] +fn an_oversized_image_plane_is_refused() { + let e = import(&eps("/DeviceCMYK setcolorspace << /ImageType 1 /Width 8192 /Height 8192 /BitsPerComponent 16 \ + /ImageMatrix [8192 0 0 8192 0 0] /DataSource <80> >> image")) + .unwrap_err(); + assert!(e.contains("too large"), "{e}"); +} + +/// Graphics states kept with `gstate` or `currentgstate` each hold a copy of the current path, and +/// the copies count against the memory budget as `gsave` does. +#[test] +fn stored_graphics_states_use_up_the_memory_budget() { + let path = "newpath 0 0 moveto 0 1 1000000 { pop 1 0 rlineto } for"; + for body in ["30 { gstate } repeat", "30 { 0 currentgstate } repeat"] { + let r = read_soon(&format!("0 0 10 10 rectfill {path} {body}")); + assert!(r.warnings.iter().any(|w| w.contains("uses too much memory")), "{body}: {:?}", r.warnings); + } +} diff --git a/crates/eps/src/import/tests_generators.rs b/crates/eps/src/import/tests_generators.rs index 49bfa4853..892bcf417 100644 --- a/crates/eps/src/import/tests_generators.rs +++ b/crates/eps/src/import/tests_generators.rs @@ -1103,3 +1103,19 @@ fn restore_puts_dictionaries_back() { // take, is positive (chapter 8), and programs divide by it. super::tests::check("cachestatus 7 1 roll 6 { pop } repeat 0 gt"); } + +/// #3567: what a `filter` decodes counts against the memory budget, not just its input. A +/// small string that inflates to 16 MiB, decoded over and over (each result dropped), ends +/// the program; a single one does not. +#[test] +fn filter_output_counts_against_the_memory_budget() { + fn hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect() + } + let z = hex(&deflate(&vec![0u8; 16 << 20])); + let once = import(&file("t", &format!("/z <{z}> def z /FlateDecode filter pop 0 0 1 1 rectfill"))).unwrap(); + assert!(once.warnings.iter().all(|w| !w.contains("memory")), "{:?}", once.warnings); + let many = import(&file("t", &format!("/z <{z}> def 100 {{ z /FlateDecode filter pop }} repeat 0 0 1 1 rectfill"))); + let Err(err) = many else { panic!("the budget ends the program") }; + assert!(err.contains("too much memory"), "{err}"); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 3fe8bc9c9..4087b2fd9 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -243,6 +243,29 @@ fn in_points(mut d: Document, k: f64) -> Document { d } +/// #1479: an image larger than the editing data's reader takes would be missing from the layers, +/// while the PDF part has it (it reads larger images): the file comes in as its PDF part, with a +/// note saying why, rather than without the image. +#[test] +fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_part() { + let raster = |w: u32, h: u32| { + format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN" + ) + }; + // 6,000 × 6,000 pixels: past the reader's 2^25. + let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(6000, 6000)))); + let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "the PDF part is used: {warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("an image is too large to read from them")), "{warnings:?}"); + assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the PDF part's art"); + // An image the reader takes keeps the layers. + let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(1, 1)))); + let (_, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(read, "{warnings:?}"); +} + #[test] fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() { // A square on the artboard, and one above it (the artboard is y 0 to 100 here). diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 4c4a45ce5..01293bbf9 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -83,7 +83,7 @@ const HEX_LINE: usize = 60; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines"; -pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs) are left out"; +pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted lists) are left out"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; @@ -678,7 +678,7 @@ impl<'a> Writer<'a> { } NodeKind::Image(im) => { self.lock(locked); - self.image(self.doc.images.get(&im.key), im.width, im.height, im.xf); + self.image(self.doc.image_blob(im), im.width, im.height, im.xf); } NodeKind::SymbolInstance { symbol, xf } => { self.warn(LIVE); @@ -1396,7 +1396,7 @@ fn lost_attributes(t: &TextObject) -> bool { || !s.features.is_empty() || !s.font_axes.is_empty() }; - let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty(); + let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty() || p.list.is_some(); t.runs.iter().any(|r| run_lost(&r.style)) || t.paragraph_styles().iter().any(para_lost) || (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default()) diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index dc2ba9a69..3f4b1049f 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -187,6 +187,10 @@ pub(crate) fn ascii85(bytes: &[u8]) -> String { } raw.push_str("~>"); let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2); + if raw.starts_with('%') { + // Whitespace is ignored by readers, and keeps the first line from starting with `%`. + out.push(' '); + } let mut col = 0; for ch in raw.chars() { if col >= LINE && ch != '%' { @@ -287,7 +291,15 @@ pub(crate) fn inflate(data: &[u8], max: u64) -> Option> { #[cfg(test)] mod tests { - use super::num; + use super::{ascii85, ascii85_decode, num}; + + #[test] + fn ascii85_first_line_never_starts_with_percent() { + let data = [0x0C, 0x80, 0, 0, 1, 2, 3, 4]; + let s = ascii85(&data); + assert!(s.lines().all(|l| !l.starts_with('%')), "got {s:?}"); + assert_eq!(ascii85_decode(&s).as_deref(), Some(&data[..])); + } #[test] fn huge_finite_numbers_are_valid_postscript() { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 36c629d00..acbd58849 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -288,7 +288,7 @@ impl<'a> Scene<'a> { self.shape(n, &bp, *rule); } NodeKind::Text(t) => self.text(n, t), - NodeKind::Image(im) => self.image(&im.key, self.doc.images.get(&im.key), im.width, im.height, im.xf), + NodeKind::Image(im) => self.image(&im.key, self.doc.image_blob(im), im.width, im.height, im.xf), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index dc6259a02..73ee2890a 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -268,6 +268,7 @@ fn png_doc() -> Document { xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); let layer = d.layers[0].id; diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index d2e9d8041..2fa1483a4 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -62,8 +62,15 @@ fn sample() -> Document { let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); doc.images.insert(key.clone(), blob); - let im = - ImageObject { key, width: 2, height: 1, xf: Affine::new([10.0, 0.0, 0.0, 10.0, 170.0, 70.0]), link: None, placement: Default::default() }; + let im = ImageObject { + key, + width: 2, + height: 1, + xf: Affine::new([10.0, 0.0, 0.0, 10.0, 170.0, 70.0]), + link: None, + placement: Default::default(), + pixels: None, + }; let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); doc.insert(Some(front), usize::MAX, im).unwrap(); if let Some(l) = doc.layers.get_mut(1) { @@ -114,7 +121,15 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { let Some((image, to_text)) = g.placed_image() else { continue }; let key = image.blob.content_key(); images.insert(key.clone(), image.blob.clone()); - let im = ImageObject { key, width: image.width, height: image.height, xf: t.xf * to_text, link: None, placement: Default::default() }; + let im = ImageObject { + key, + width: image.width, + height: image.height, + xf: t.xf * to_text, + link: None, + placement: Default::default(), + pixels: None, + }; nodes.push(Arc::new(Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))))); } // COLR glyphs: their layers. @@ -264,8 +279,10 @@ fn a_rotated_image_keeps_its_place() { doc.images.insert(key.clone(), blob); // Turned 90° clockwise on the page: the pixel rows run down, the next row is to the left. let xf = Affine::new([0.0, 10.0, -10.0, 0.0, 100.0, 40.0]); - let im = - Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 2, height: 1, xf, link: None, placement: Default::default() }))); + let im = Node::new( + doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: 2, height: 1, xf, link: None, placement: Default::default(), pixels: None })), + ); doc.layers.clear(); let layer = doc.add_layer(Some("Art")); doc.insert(Some(layer), usize::MAX, im).unwrap(); @@ -297,7 +314,10 @@ fn picture(doc: &mut Document, r: Rect) -> Node { let key = blob.content_key(); doc.images.insert(key.clone(), blob); let xf = Affine::new([r.width() / 16.0, 0.0, 0.0, r.height() / 16.0, r.x0, r.y0]); - Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 16, height: 16, xf, link: None, placement: Default::default() }))) + Node::new( + doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: 16, height: 16, xf, link: None, placement: Default::default(), pixels: None })), + ) } /// A layer `Below` holding a clipping group (a circle clipping an image, as in #2692), a group @@ -411,7 +431,7 @@ fn empty_images_get_the_size_note_and_undecodable_ones_the_decode_note() { let blob = ImageBlob::new("image/png", bytes); let key = blob.content_key(); doc.images.insert(key.clone(), blob); - let im = ImageObject { key, width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key, width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; let node = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); doc.insert(Some(layer), usize::MAX, node).unwrap(); let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap(); @@ -997,6 +1017,21 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// A legacy `.ai` paragraph has no list: type with a bulleted list is written as its text, with the +/// warning that the bullets are left out. +#[test] +fn bulleted_lists_are_warned_as_left_out() { + let mut doc = typed(); + let id = texts(&doc)[0].0.id; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Square)); + t.normalize_paras(); + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES && w.contains("bulleted lists")), "{:?}", out.warnings); + assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); +} + #[test] fn type_reads_back_as_text() { use vectorcraft_doc::{Justify, TextKind}; diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 2c8fba185..832db19a6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -40,6 +40,7 @@ mod unpack; use std::borrow::Cow; use std::collections::{BTreeMap, HashSet}; use std::io::{Read as _, Write}; +use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; @@ -534,15 +535,20 @@ pub fn load_file(bytes: &[u8]) -> Result { } // The bytes after the JSON, read in one pass: the images' first, then the profiles'. let entries: Vec<[u64; 2]> = f.images.values().chain(f.profiles.values()).filter_map(|i| i.blob).collect(); - let mut tail = file.entries(&entries)?.into_iter(); + let tail = file.entries(&entries)?; + // Entries naming the same bytes share them; a profile's own copy of shared bytes counts + // against the data's size, so copies never add up to more than the file has (#3574). + let mut distinct = HashSet::new(); + let mut spare: u64 = tail.iter().flatten().filter(|b| distinct.insert(Arc::as_ptr(b))).map(|b| b.len() as u64).sum(); + let mut tail = tail.into_iter(); let mut bytes_of = |img: &Image| match img.blob { Some(_) => tail.next().flatten(), - None => img.inline(), + None => img.inline().map(Arc::new), }; let mut doc = f.document; for (k, img) in f.images { let bytes = bytes_of(&img).ok_or_else(|| FormatError::BadImage(k.clone()))?; - let mut blob = ImageBlob::new(img.mime, bytes); + let mut blob = ImageBlob { mime: img.mime, bytes, proxy: None }; // Until the linked file is read, the preview stands in for it. if img.proxy { blob.proxy = Some(blob.bytes.clone()); @@ -575,7 +581,18 @@ pub fn load_file(bytes: &[u8]) -> Result { // labels and the character styles, within the ranges `document.setup` takes (after the depth // limit: the walk recurses). doc.keep_scripts_in_range(); - let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); + let profiles = f + .profiles + .into_iter() + .filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))) + .map(|(name, icc)| match Arc::try_unwrap(icc) { + Ok(icc) => Ok((name, icc)), + Err(icc) => { + spare = spare.checked_sub(icc.len() as u64).ok_or(FormatError::TooBig)?; + Ok((name, icc.to_vec())) + } + }) + .collect::>()?; Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } @@ -593,7 +610,7 @@ fn head_member(bytes: &[u8], pick: impl FnOnce(Head) -> Option) -> Option let head: Head = serde_json::from_slice(file.json()).ok()?; let img = pick(head)?; match img.blob { - Some(at) => file.entries(&[at]).ok()?.pop().flatten(), + Some(at) => file.entries(&[at]).ok()?.pop().flatten().map(Arc::unwrap_or_clone), None => img.inline(), } } @@ -708,7 +725,7 @@ mod tests { d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250])); // Only images in use are saved. let img = d.alloc_id(); - let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); let bytes = save(&d, true); assert!(sniff(&bytes)); diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 6e9af01d4..9aa3c81b2 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -3,7 +3,9 @@ //! the file is never held unpacked as a whole. Limits apply per part ([`MAX_JSON`], [`MAX_BLOB`], //! [`MAX_BLOBS`]), guarding against decompression bombs. +use std::collections::HashMap; use std::io::{self, Read}; +use std::sync::Arc; use flate2::read::GzDecoder; @@ -18,6 +20,9 @@ fn damaged(e: impl std::fmt::Display) -> FormatError { FormatError::NotVectorcraft(format!("damaged compressed data: {e}")) } +/// An entry's bytes (`None`: the data ends before it), shared by the entries naming them. +pub(crate) type Entry = Option>>; + /// A file opened for reading: its JSON, and where the data after it is. pub(crate) struct Unpacked<'a> { json: Json<'a>, @@ -116,27 +121,43 @@ impl<'a> Unpacked<'a> { } /// The entries `[offset, length]` of the data after the JSON → each one's bytes, `None` for - /// one that isn't there (the data ends before it). Errors: the compressed data is damaged, or - /// the entries of a compressed file are larger than [`MAX_BLOB`] each or [`MAX_BLOBS`] - /// together, or those of an uncompressed file larger together than both [`MAX_BLOBS`] and the - /// data (entries sharing bytes, only in hand-made files, would copy them once each). Entries - /// are read in the order of their offsets, each straight into its own buffer. - pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result>>, FormatError> { + /// one that isn't there (the data ends before it). Entries naming the same bytes (only in + /// hand-made files) share one buffer. Errors: the compressed data is damaged, or the entries + /// of a compressed file are larger than [`MAX_BLOB`] each or [`MAX_BLOBS`] together, or those + /// of an uncompressed file larger together than both [`MAX_BLOBS`] and the data, or the + /// distinct entries found larger together than the data they are in (overlapping entries + /// would copy the same bytes once each, #3574). Entries are read in the order of their + /// offsets, each straight into its own buffer. + pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result, FormatError> { self.entries_within(wanted, MAX_WORK) } /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. - fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result>>, FormatError> { - let mut out: Vec>> = vec![None; wanted.len()]; - let in_memory = |tail: &[u8], out: &mut Vec>>| { - for (slot, &[offset, len]) in out.iter_mut().zip(wanted) { - *slot = range(offset, len).and_then(|(a, b)| tail.get(a..b)).map(<[u8]>::to_vec); + fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result, FormatError> { + let mut out: Vec = vec![None; wanted.len()]; + // The entries read so far, by `[offset, length]`, and the bytes copied for them. + let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); + let mut copied = 0u64; + let mut in_memory = |tail: &[u8], out: &mut Vec| { + for (slot, &w) in out.iter_mut().zip(wanted) { + if let Some(e) = done.get(&w) { + *slot = e.clone(); + continue; + } + let bytes = range(w[0], w[1]).and_then(|(a, b)| tail.get(a..b)); + copied = copied.saturating_add(bytes.map_or(0, |b| b.len() as u64)); + if copied > tail.len() as u64 { + return Err(FormatError::TooBig); + } + *slot = bytes.map(|b| Arc::new(b.to_vec())); + done.insert(w, slot.clone()); } + Ok(()) }; match self.tail { Tail::Slice(tail) => { within(wanted, u64::MAX, MAX_BLOBS.max(tail.len() as u64))?; - in_memory(tail, &mut out); + in_memory(tail, &mut out)?; } Tail::Whole(at) => { within(wanted, MAX_BLOB, MAX_BLOBS)?; @@ -144,20 +165,31 @@ impl<'a> Unpacked<'a> { Json::Whole { buf, .. } => buf.as_slice(), _ => &[], }; - in_memory(buf.get(at..).unwrap_or_default(), &mut out); + in_memory(buf.get(at..).unwrap_or_default(), &mut out)?; } - Tail::None => in_memory(&[], &mut out), + Tail::None => in_memory(&[], &mut out)?, Tail::Stream { file, at, read, rest } => { within(wanted, MAX_BLOB, MAX_BLOBS)?; drop(self.json); let mut order: Vec = (0..wanted.len()).collect(); order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0])); let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at, left: work }; + // How far into the data the stream has been: the bytes copied stay within it. + let mut seen = 0u64; for i in order { - let Some(&[offset, len]) = wanted.get(i) else { continue }; - if let Some(slot) = out.get_mut(i) { - *slot = s.entry(offset, len)?; + let Some(&w) = wanted.get(i) else { continue }; + let Some(slot) = out.get_mut(i) else { continue }; + if let Some(e) = done.get(&w) { + *slot = e.clone(); + continue; } + *slot = s.entry(w[0], w[1])?.map(Arc::new); + copied = copied.saturating_add(slot.as_ref().map_or(0, |e| e.len() as u64)); + seen = seen.max(s.pos); + if copied > seen { + return Err(FormatError::TooBig); + } + done.insert(w, slot.clone()); } // To the end, so the stream's checksum is verified as before. s.left = MAX_BLOBS; @@ -278,17 +310,17 @@ mod tests { }; // Entries one after the other: the tail is unpacked once. let parts = read(&[[0, 1000], [1000, 3096]], 4096).unwrap(); - assert_eq!(parts, vec![Some(tail[..1000].to_vec()), Some(tail[1000..].to_vec())]); + assert_eq!(parts, vec![Some(Arc::new(tail[..1000].to_vec())), Some(Arc::new(tail[1000..].to_vec()))]); // One byte at the far end is the whole tail's work. - assert_eq!(read(&[[4095, 1]], 4096).unwrap(), vec![Some(vec![tail[4095]])]); + assert_eq!(read(&[[4095, 1]], 4096).unwrap(), vec![Some(Arc::new(vec![tail[4095]]))]); assert!(matches!(read(&[[4095, 1]], 4000), Err(FormatError::TooBig))); // Entries over the same bytes start the stream again each time: counted, then refused. - let same = vec![[4000, 96]; 50]; + let same: Vec<[u64; 2]> = (0..50).map(|i| [4000, 1 + i]).collect(); assert!(read(&same, 50 * (4096 + 64)).is_ok()); assert!(matches!(read(&same, 3 * 4096), Err(FormatError::TooBig))); // An offset past the data is still just missing, and the default budget reads all of it. assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); - assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(tail.clone())]); + assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); } #[test] @@ -303,8 +335,61 @@ mod tests { assert!(matches!(Unpacked::new(&whole).unwrap().entries(&[[0, MAX_BLOB + 1]]), Err(FormatError::TooBig))); // Uncompressed files keep no per-entry limit: a length the data doesn't have is just missing. assert_eq!(Unpacked::new(b"{}").unwrap().entries(&[[0, MAX_BLOB + 1]]).unwrap(), vec![None]); - // Within the limits, shared bytes are still read once per entry. + // Within the limits, entries naming the same bytes share them. let file = b"{}\n\0VCBLOBS\0abcd"; - assert_eq!(Unpacked::new(file).unwrap().entries(&[[0, 4], [0, 4]]).unwrap(), vec![Some(b"abcd".to_vec()), Some(b"abcd".to_vec())]); + let shared = Unpacked::new(file).unwrap().entries(&[[0, 4], [0, 4]]).unwrap(); + assert_eq!(shared, vec![Some(Arc::new(b"abcd".to_vec())), Some(Arc::new(b"abcd".to_vec()))]); + assert!(Arc::ptr_eq(shared[0].as_ref().unwrap(), shared[1].as_ref().unwrap())); + } + + /// The bytes held for `entries`, each buffer counted once however many entries share it. + macro_rules! stored { + ($entries:expr) => {{ + let mut bufs = std::collections::HashSet::new(); + $entries.iter().flatten().filter(|e| bufs.insert(e.as_ptr())).map(|e| e.len()).sum::() + }}; + } + + /// #3574: entries naming the same bytes, or overlapping ones, would copy them once each, far + /// past the data the file has: they share one buffer or are refused, in memory and streamed. + #[test] + fn entries_sharing_bytes_hold_no_more_than_the_data() { + let tail: Vec = (0..65536u32).map(|i| (i % 251) as u8).collect(); + let n = tail.len() as u64; + let same = vec![[0, n]; 50]; + let overlapping: Vec<[u64; 2]> = (0..50).map(|i| [i, n - i]).collect(); + let with = |json: &[u8]| { + let mut file = json.to_vec(); + file.extend(crate::BLOB_MAGIC); + file.extend(&tail); + file + }; + let plain = with(b"{}"); + // Compressed, unpacked whole (the marker isn't right after a `}`), and streamed. + let whole = gzip(&with(b"1")); + let streamed = gzip(&plain); + for file in [&plain, &whole, &streamed] { + let u = Unpacked::new(file).unwrap(); + let kind = match u.tail { + Tail::Slice(_) => "in place", + Tail::Whole(_) => "whole", + Tail::Stream { .. } => "streamed", + Tail::None => "no data", + }; + match u.entries(&same) { + Ok(e) => { + assert!(stored!(e) <= tail.len(), "{kind}: {} bytes held for {} of data", stored!(e), tail.len()); + assert!(e.iter().all(|e| e.as_deref().map(|b| &b[..]) == Some(&tail[..])), "{kind}"); + } + Err(e) => assert!(matches!(e, FormatError::TooBig), "{kind}: {e}"), + } + match Unpacked::new(file).unwrap().entries(&overlapping) { + Ok(e) => assert!(stored!(e) <= tail.len(), "{kind}: {} bytes held for {} of data", stored!(e), tail.len()), + Err(e) => assert!(matches!(e, FormatError::TooBig), "{kind}: {e}"), + } + // One entry over all of it is fine. + let one = Unpacked::new(file).unwrap().entries(&same[..1]).unwrap(); + assert_eq!(one[0].as_deref().map(|b| &b[..]), Some(&tail[..]), "{kind}"); + } } } diff --git a/crates/format/tests/links.rs b/crates/format/tests/it/links.rs similarity index 91% rename from crates/format/tests/links.rs rename to crates/format/tests/it/links.rs index 8b8fff3ac..e5a789f0d 100644 --- a/crates/format/tests/links.rs +++ b/crates/format/tests/it/links.rs @@ -1,7 +1,5 @@ //! Linked images in `.vectorcraft` files: their link details round-trip, and an image only linked //! images show is saved as its preview. -// 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::sync::Arc; @@ -12,7 +10,7 @@ use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option) { let (id, layer) = (d.alloc_id(), d.layers[0].id); - let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); } diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs new file mode 100644 index 000000000..a5f028c38 --- /dev/null +++ b/crates/format/tests/it/main.rs @@ -0,0 +1,9 @@ +//! 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 links; +mod native_v3; +mod native_v4; +mod save_options; +mod streaming; diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/it/native_v3.rs similarity index 98% rename from crates/format/tests/native_v3.rs rename to crates/format/tests/it/native_v3.rs index fcbc43d32..33633de5c 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -1,7 +1,5 @@ //! `.vectorcraft` v3: compression, pruning unused images, keys from newer versions, colour //! profiles as a document field, saving for older versions and the embedded preview. -// 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 as _; use std::sync::Arc; @@ -42,6 +40,7 @@ fn image_node(d: &mut Document, key: &str) -> Node { xf: Affine::IDENTITY, link: None, placement: Default::default(), + pixels: None, })), ) } diff --git a/crates/format/tests/native_v4.rs b/crates/format/tests/it/native_v4.rs similarity index 95% rename from crates/format/tests/native_v4.rs rename to crates/format/tests/it/native_v4.rs index a8071fa43..c24231651 100644 --- a/crates/format/tests/native_v4.rs +++ b/crates/format/tests/it/native_v4.rs @@ -1,7 +1,5 @@ //! `.vectorcraft` v4: large data after the JSON (images, the PDF, profiles), small files still plain //! JSON, older versions inline, damaged offsets refused. -// 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::sync::Arc; @@ -17,7 +15,7 @@ fn doc_with_image(key: &str, len: usize) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let l = d.layers[0].id; let id = d.alloc_id(); - let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/format/tests/save_options.rs b/crates/format/tests/it/save_options.rs similarity index 97% rename from crates/format/tests/save_options.rs rename to crates/format/tests/it/save_options.rs index ede14a41d..41b91a9c5 100644 --- a/crates/format/tests/save_options.rs +++ b/crates/format/tests/it/save_options.rs @@ -1,8 +1,6 @@ //! The native save options of the file itself: Include Linked Files, embedded ICC profiles and the //! PDF a PDF-compatible file carries, each round-tripping (and ignored by readers that don't //! need them). -// 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::sync::Arc; @@ -30,6 +28,7 @@ fn linked_doc() -> Document { xf: Affine::IDENTITY, link: Some(LinkInfo::new("/art/photo.png")), placement: Default::default(), + pixels: None, }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); diff --git a/crates/format/tests/streaming.rs b/crates/format/tests/it/streaming.rs similarity index 96% rename from crates/format/tests/streaming.rs rename to crates/format/tests/it/streaming.rs index 6149f1995..93a58f894 100644 --- a/crates/format/tests/streaming.rs +++ b/crates/format/tests/it/streaming.rs @@ -2,8 +2,6 @@ //! before (the same JSON and data after it, unpacked; the same gzip header), files written the old //! way still open, entries are read whatever their order, and the limits apply per part of the //! file. -// 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::collections::BTreeMap; use std::io::{Read, Write}; @@ -20,7 +18,8 @@ fn doc_with(images: &[(&str, &str, usize)]) -> Document { let bytes: Vec = (0..len).map(|i| ((i * 7 + n * 13) % 251) as u8).collect(); d.images.insert(key.into(), ImageBlob::new(mime, bytes)); let id = d.alloc_id(); - let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = + ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); } d diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 66b08b264..6936feea2 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -75,7 +75,7 @@ proptest! { d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone())); let layer = d.layers[0].id; let id = d.alloc_id(); - let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); for pretty in [false, true] { let back = load(&save(&d, pretty)).unwrap(); diff --git a/crates/geom/src/arc.rs b/crates/geom/src/arc.rs index de66388de..c4fe640ae 100644 --- a/crates/geom/src/arc.rs +++ b/crates/geom/src/arc.rs @@ -22,7 +22,7 @@ impl ArcPath { let mut segs = Vec::new(); let mut cum = 0.0; for s in path.to_bezpath().segments() { - let l = s.arclen(ACCURACY); + let l = crate::path::measured_arclen(&s, ACCURACY); if l.is_finite() && l > 1e-9 { segs.push((s, cum, l)); cum += l; @@ -51,7 +51,8 @@ impl ArcPath { let s = if self.closed && self.len > 0.0 { s.rem_euclid(self.len) } else { s.clamp(0.0, self.len) }; let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1); let Some(&(seg, c, l)) = self.segs.get(i) else { return (Point::ZERO, Vec2::new(1.0, 0.0)) }; - let t = seg.inv_arclen((s - c).min(l), ACCURACY).clamp(0.0, 1.0); + // (The solver's precision is relative to the segment's length: a long one needs a looser one.) + let t = seg.inv_arclen((s - c).min(l), ACCURACY.max(l * 1e-9)).clamp(0.0, 1.0); let p = seg.eval(t); let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0)); let d = seg.eval(t1) - seg.eval(t0); diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 46449f43a..6fe68e576 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -39,7 +39,11 @@ impl Side { /// The side's length. fn length(&self) -> f64 { - if self.straight() { self.curve.p0.distance(self.curve.p3) } else { self.curve.arclen(ARCLEN_ACCURACY) } + if self.straight() { + self.curve.p0.distance(self.curve.p3) + } else { + crate::path::measured_arclen(&kurbo::PathSeg::Cubic(self.curve), ARCLEN_ACCURACY) + } } /// The parameter `s` along the side from the corner, the point there and the direction the diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 110997efc..f7828fb80 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -561,10 +561,27 @@ impl PathData { } /// Total length of all segments. pub fn length(&self) -> f64 { - self.to_bezpath().segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-6)).sum() + self.to_bezpath().segments().map(|s| measured_arclen(&s, 1e-6)).sum() } } +/// The largest x or y, in size, of a segment's control points for which its length is measured. +/// Document coordinates are far inside it; past it (or not finite) the integrator's arithmetic +/// overflows and it subdivides without end. +const MEASURE_LIMIT: f64 = 1e9; + +/// The length of `seg` to `accuracy`; 0 for a segment whose control points are not finite or past +/// [`MEASURE_LIMIT`]. +pub(crate) fn measured_arclen(seg: &PathSeg, accuracy: f64) -> f64 { + let ok = |p: Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MEASURE_LIMIT && p.y.abs() <= MEASURE_LIMIT; + let fits = match seg { + PathSeg::Line(l) => ok(l.p0) && ok(l.p1), + PathSeg::Quad(q) => ok(q.p0) && ok(q.p1) && ok(q.p2), + PathSeg::Cubic(c) => ok(c.p0) && ok(c.p1) && ok(c.p2) && ok(c.p3), + }; + if fits { kurbo::ParamCurveArclen::arclen(seg, accuracy) } else { 0.0 } +} + /// The largest x or y, in size, of a segment's control points for which [`segment_bounds`] /// measures the segment by its extrema. kurbo adds up to eight coordinates together when it finds a /// segment's extrema and evaluates the segment there, and a sum of eight coordinates of this size or diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 3d7048ba8..ba50f2ebc 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -279,7 +279,8 @@ impl Headless { /// `app.open {path}`: any readable file as a new active document (`document.open`). pub fn open(&mut self, p: &Value) -> Result { let path = s(p, "path").ok_or("missing `path`")?; - self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string()) + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.session.execute("document.open", &json!({"path": path}))).map_err(|e| e.to_string()) } /// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and @@ -289,12 +290,14 @@ impl Headless { if s(p, "path").is_none() && st.path.is_none() { return Err("missing `path` (document was never saved)".into()); } - self.session.execute("document.save", p).map_err(|e| e.to_string()) + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.session.execute("document.save", p)).map_err(|e| e.to_string()) } /// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension). pub fn export(&mut self, p: &Value) -> Result { - self.session.execute("document.export", p).map_err(|e| e.to_string()) + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string()) } } diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 2dff4486e..0c0a43926 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -405,7 +405,10 @@ pub fn get(name: &str, args: &Value) -> Result { let ids: Vec<&str> = PROMPTS.iter().map(|p| p.name).collect(); return Err(format!("unknown prompt `{name}` ({})", ids.join("|"))); }; - let mut text = flow(p.text); + let template = flow(p.text); + // (placeholder, value): filled in one pass over the template, so a value that looks like a + // placeholder stays as it is. + let mut values: Vec<(String, String)> = vec![]; for a in p.arguments { let given = args.get(a.name).filter(|v| !v.is_null()).map(|v| match v { Value::String(s) => s.clone(), @@ -416,7 +419,21 @@ pub fn get(name: &str, args: &Value) -> Result { _ if a.required => return Err(format!("prompt `{name}` needs the `{a_name}` argument", a_name = a.name)), _ => a.default.to_string(), }; - text = text.replace(&format!("{{{}}}", a.name), &value); + values.push((format!("{{{}}}", a.name), value)); + } + let mut text = String::with_capacity(template.len()); + let mut rest = template.as_str(); + while let Some(c) = rest.chars().next() { + match values.iter().find(|(placeholder, _)| rest.starts_with(placeholder.as_str())) { + Some((placeholder, value)) => { + text.push_str(value); + rest = rest.get(placeholder.len()..).unwrap_or_default(); + } + None => { + text.push(c); + rest = rest.get(c.len_utf8()..).unwrap_or_default(); + } + } } Ok(json!({ "description": p.description, diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index 2c76d658c..d45ecc714 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -114,7 +114,13 @@ fn saves_exports_and_captures_go_only_into_the_write_root() { json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("outside"))}}), "outside the write root", ); - ok(&mut server, "run_command", json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}})); + // Data Recovery lists and restores what is in its folder too: write access alone isn't enough. + refused( + &mut server, + "run_command", + json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}}), + "outside the read root", + ); refused(&mut server, "run_command", json!({"command": "plugin.reload", "params": {"path": s(&base.join("outside"))}}), "outside the read root"); // The document saved inside the write root saves again in place. @@ -155,3 +161,12 @@ fn links_out_of_a_root_are_refused() { refused(&mut server, "export", json!({"path": s(&base.join("out/away/art.png"))}), "outside the write root"); assert!(!base.join("outside/art.png").exists()); } + +/// The Place tool keeps its queue until a click, which no scope confines: a queued file outside the +/// read root is refused when the queue is set (whichever tool is active when it is). +#[test] +fn a_place_queue_names_only_files_inside_the_read_root() { + let (mut server, base) = confined("place-queue", true, true); + let queue = |file: &str| json!({"key": "queue", "value": {"entries": [{"params": {"path": s(&base.join(file))}, "name": "x", "width": 10, "height": 10}]}}); + refused(&mut server, "run_command", json!({"command": "tool.setOption", "params": queue("outside/b.svg")}), "outside the read root"); +} diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 047a59f5f..c56293022 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -788,7 +788,7 @@ impl Player { if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { return; } - let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; + let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; self.push(n); diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 06256e686..db55fc71a 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -33,6 +33,8 @@ const CLIPPING: &str = "clipping masks are not applied: the art they clip is wri const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops"; const FLAT_PATTERNS: &str = "pattern fills are written as one colour"; const TOO_MANY_TILES: &str = "pattern fills past 250,000 tiles in all are written as one colour"; +const TEXT_OUTLINES: &str = "type is written as outlines: it can't be searched, selected or read aloud as text"; +const PATTERN_TILES: &str = "pattern fills are written as their tiles, one shape each"; /// The warning for an image left out because it can't be decoded: the note raster exports give /// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; @@ -197,6 +199,7 @@ impl<'a> Scene<'a> { } NodeKind::Text(_) => { if let Some(o) = vectorcraft_effects::outline_text(n) { + self.warn(TEXT_OUTLINES); self.node(&o, true); } } @@ -316,6 +319,9 @@ impl<'a> Scene<'a> { return; } self.tiles_left -= tiles.len(); + if !tiles.is_empty() { + self.warn(PATTERN_TILES); + } let outer = self.opacity; self.opacity *= opacity.clamp(0.0, 1.0); self.ops.push(Op::Clip { path: bp.clone(), rule }); @@ -433,19 +439,20 @@ impl<'a> Scene<'a> { /// A placed image, decoded once per image. fn image(&mut self, im: &ImageObject) { - let Some(img) = self.decoded(&im.key) else { return }; + let Some(img) = self.decoded(im) else { return }; let (dw, dh) = (f64::from(img.width), f64::from(img.height)); let xf = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)) / dw, f64::from(im.height.max(1)) / dh); self.ops.push(Op::Image { image: img, xf, opacity: self.opacity.clamp(0.0, 1.0) }); } - /// Image `key`'s pixels (scaled down to what a metafile stores), `None` with a warning when it + /// Image `im`'s pixels (scaled down to what a metafile stores), `None` with a warning when it /// has none or can't be decoded. - fn decoded(&mut self, key: &str) -> Option> { + fn decoded(&mut self, im: &ImageObject) -> Option> { + let key = im.key.as_str(); if let Some(i) = self.images.get(key) { return i.clone(); } - let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()); + let blob = self.doc.image_blob(im).filter(|b| !b.bytes.is_empty()); let img = blob .and_then(|b| image::load_from_memory(&b.bytes).ok()) .map(|i| Rgba::fitted(i.to_rgba8())) diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 330de5e7b..122653b34 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -51,7 +51,10 @@ pub(crate) fn image_node(d: &mut Document, bytes: Vec, w: u32, h: u32, xf: A let blob = ImageBlob::new("image/png", bytes); let key = blob.content_key(); d.images.insert(key.clone(), blob); - Node::new(NodeId(0), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))) + Node::new( + NodeId(0), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ) } pub(crate) fn u32_at(b: &[u8], at: usize) -> u32 { @@ -253,9 +256,12 @@ fn emf_clips_gradients_and_images() { fn type_is_written_as_outlines() { let t = TextObject::point(Point::new(10.0, 50.0), "Hi", CharStyle { fill: red(), ..CharStyle::default() }); let d = doc_with(100.0, 100.0, vec![Node::new(NodeId(0), NodeKind::Text(Box::new(t)))]); - let k = kinds(&emf_records(&emf(&d).bytes)); + let out = emf(&d); + let k = kinds(&emf_records(&out.bytes)); assert!(k.contains(&62), "glyphs are filled paths"); assert!(!k.contains(&84) && !k.contains(&83), "no text records"); + // Said so (#3850). + assert!(out.warnings.iter().any(|w| w.contains("type is written as outlines")), "{:?}", out.warnings); } #[test] @@ -365,6 +371,8 @@ fn patterns_inside_pattern_tiles_share_one_tile_budget() { let (ops, warnings) = crate::scene::Scene::new(&d, d.artboards[0].rect, true).run(); assert!(ops.len() < 300_000, "{} operations", ops.len()); assert!(warnings.iter().any(|w| w.contains("one colour")), "{warnings:?}"); + // The tiles that were written are reported too (#3850). + assert!(warnings.iter().any(|w| w.contains("written as their tiles")), "{warnings:?}"); // A tile filled with its own pattern multiplies at every level down to the nesting limit. let square = path(shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 4.0)), pattern("self"), Paint::None, 0.0); diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 1fef1c539..75a201753 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -1,8 +1,10 @@ //! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average, //! Join, Split Into Grid. +use std::collections::{BTreeMap, BTreeSet}; + use kurbo::{CubicBez, ParamCurveArclen, Point, Rect, Vec2}; -use vectorcraft_geom::{Anchor, PathData, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; use crate::boolean::{Seg, segs_to_subpath}; use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent}; @@ -69,64 +71,180 @@ fn turn_deg(a: &Seg, b: &Seg) -> f64 { /// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold. pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { - let tol = opts.tolerance.max(1e-6); let mut out = Vec::new(); for sp in &path.subpaths { - let mut segs = subpath_segs(sp); - let n = segs.len(); - if n == 0 { - out.push(sp.clone()); - continue; - } - let corner_at = |segs: &[Seg], i: usize| -> bool { - if !sp.closed && (i == 0 || i == n) { - return true; - } - turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg - }; - let mut corners: Vec = (0..n).filter(|&i| corner_at(&segs, i)).collect(); - if sp.closed { - if let Some(&k) = corners.first() { - segs.rotate_left(k); - corners = corners.iter().map(|&c| c - k).collect(); - } else { - corners = vec![0]; - } - corners.push(n); - } else if corners.last() != Some(&n) { - corners.push(n); - } - let mut result: Vec = Vec::new(); - for w in corners.windows(2) { - let run = &segs[w[0]..w[1]]; - if run.is_empty() { - continue; - } - let mut pts = Vec::new(); - for (k, s) in run.iter().enumerate() { - let len = s.c.arclen(1e-6); - let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); - sample(&s.c, m, &mut pts, k == 0); - } - if opts.straight_lines { - let keep = rdp(&pts, tol); - result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); - continue; - } - let t0 = start_tangent(&run[0].c); - let t1 = end_tangent(&run[run.len() - 1].c); - for c in fit_cubics(&pts, t0, t1, tol) { - let line = is_straight(&c, tol * 0.02); - result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); - } - } - if let Some(s) = segs_to_subpath(&result, sp.closed) { - out.push(if opts.straight_lines { s } else { no_worse(sp, s) }); + if let Some(s) = simplify_subpath(sp, opts) { + out.push(s); } } PathData::new(out) } +/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. +fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { + let mut segs = subpath_segs(sp); + let n = segs.len(); + if n == 0 { + return Some(sp.clone()); + } + if sp.closed { + // Start the loop at its first corner, so no fit runs across one. + let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0); + segs.rotate_left(k); + } + let result = simplify_run(&segs, opts); + let s = segs_to_subpath(&result, sp.closed)?; + Some(if opts.straight_lines { s } else { no_worse(sp, s) }) +} + +/// The refit of the consecutive segments `run`, split at its corners sharper than the threshold: +/// it starts and ends where `run` does, leaving the same way. +fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec { + let tol = opts.tolerance.max(1e-6); + let mut cuts: Vec = (1..run.len()).filter(|&i| turn_deg(&run[i - 1], &run[i]) > opts.corner_angle_deg).collect(); + cuts.insert(0, 0); + cuts.push(run.len()); + let mut result: Vec = Vec::new(); + for w in cuts.windows(2) { + let piece = &run[w[0]..w[1]]; + if piece.is_empty() { + continue; + } + let mut pts = Vec::new(); + for (k, s) in piece.iter().enumerate() { + let len = s.c.arclen(1e-6); + let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); + sample(&s.c, m, &mut pts, k == 0); + } + if opts.straight_lines { + let keep = rdp(&pts, tol); + result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); + continue; + } + let t0 = start_tangent(&piece[0].c); + let t1 = end_tangent(&piece[piece.len() - 1].c); + for c in fit_cubics(&pts, t0, t1, tol) { + let line = is_straight(&c, tol * 0.02); + result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); + } + } + result +} + +/// What [`simplify_part`] made of a path. +#[derive(Clone, Debug, PartialEq)] +pub struct SimplifiedPart { + pub path: PathData, + /// The segments of the simplified runs, as (subpath, segment) in `path`. + pub segments: BTreeSet<(usize, usize)>, + /// Where each anchor of the original path that is still there went: (subpath, anchor) in the + /// original → in `path`. Anchors inside a simplified run are gone; every other anchor is kept. + pub anchors: BTreeMap<(usize, usize), (usize, usize)>, +} + +/// Simplify only the `segments` (subpath, segment) of `path`: each run of consecutive segments +/// among them (a closed subpath's run may cross its start point) is refitted on its own between its +/// two end anchors, which stay where they are with their handles on the side away from the run. +/// The other segments and anchors stay exactly as they are. A subpath whose every segment is in +/// `segments` is simplified whole, as [`simplify_with`] does. A run whose refit would have more +/// segments than it had is left as it is (except with Convert to Straight Lines). +pub fn simplify_part(path: &PathData, segments: &BTreeSet<(usize, usize)>, opts: &SimplifyOptions) -> SimplifiedPart { + let (mut runs, mut anchors) = (BTreeSet::new(), BTreeMap::new()); + let mut subpaths = Vec::with_capacity(path.subpaths.len()); + for (si, sp) in path.subpaths.iter().enumerate() { + let n = sp.segment_count(); + let picked: Vec = (0..n).map(|i| segments.contains(&(si, i))).collect(); + let ni = subpaths.len(); + if !picked.contains(&true) { + anchors.extend((0..sp.anchors.len()).map(|ai| ((si, ai), (ni, ai)))); + subpaths.push(sp.clone()); + } else if !picked.contains(&false) { + if let Some(s) = simplify_subpath(sp, opts) { + runs.extend((0..s.segment_count()).map(|i| (ni, i))); + subpaths.push(s); + } + } else { + let (s, segs, kept) = simplify_runs(sp, &picked, opts); + runs.extend(segs.into_iter().map(|i| (ni, i))); + anchors.extend(kept.into_iter().map(|(old, new)| ((si, old), (ni, new)))); + subpaths.push(s); + } + } + SimplifiedPart { path: PathData::new(subpaths), segments: runs, anchors } +} + +/// [`simplify_part`] for a subpath with some but not all segments `picked`: → (the subpath, the +/// segments of its refitted runs, old → new index of each anchor kept). +fn simplify_runs(sp: &SubPath, picked: &[bool], opts: &SimplifyOptions) -> (SubPath, Vec, Vec<(usize, usize)>) { + let m = sp.anchors.len(); + let n = picked.len(); + // An anchor inside a run (both its segments picked) goes; a closed subpath's walk starts at the + // first anchor that stays, so that no run crosses the start of the walk. + let inside = |a: usize| (sp.closed && a == 0 && picked[n - 1] && picked[0]) || (a > 0 && a < n && picked[a - 1] && picked[a]); + let start = (0..m).find(|&a| !inside(a)).unwrap_or(0); + let mut anchors = vec![sp.anchors[start]]; + let mut kept = vec![(start, 0)]; + let mut runs = vec![]; + let mut k = 0; + while k < n { + let j = (start + k) % n; + if !picked[j] { + let next = (j + 1) % m; + if k + 1 < n || !sp.closed { + kept.push((next, anchors.len())); + anchors.push(sp.anchors[next]); + } + k += 1; + continue; + } + let len = (k..n).take_while(|&i| picked[(start + i) % n]).count(); + let run: Vec = (k..k + len).map(|i| (start + i) % n).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect(); + let end = (start + k + len) % m; + let fitted = simplify_run(&run, opts); + let keep_original = fitted.is_empty() || !opts.straight_lines && fitted.len() > run.len(); + let first = anchors.len() - 1; + if keep_original { + anchors.extend((k + 1..k + len).map(|i| sp.anchors[(start + i) % m])); + } else { + let a = anchors[first]; + anchors[first] = with_handle(a, true, if fitted[0].line { a.p } else { fitted[0].c.p1 }); + for w in fitted.windows(2) { + let p = w[1].c.p0; + let h_in = if w[0].line { p } else { w[0].c.p2 }; + let h_out = if w[1].line { p } else { w[1].c.p1 }; + anchors.push(Anchor::with_handles(p, h_in, h_out)); + } + } + // The run's segments, from its first anchor on: a run back to the walk's start ends with + // the closing segment, the last index. + runs.extend(first..first + if keep_original { run.len() } else { fitted.len() }); + let end_seg = fitted.last().filter(|_| !keep_original); + if sp.closed && k + len == n { + // The run ends where the walk started. + if let Some(s) = end_seg { + anchors[0] = with_handle(anchors[0], false, if s.line { anchors[0].p } else { s.c.p2 }); + } + } else { + let a = sp.anchors[end]; + kept.push((end, anchors.len())); + anchors.push(match end_seg { + Some(s) => with_handle(a, false, if s.line { a.p } else { s.c.p2 }), + None => a, + }); + } + k += len; + } + (SubPath::new(anchors, sp.closed), runs, kept) +} + +/// `a` with its out (`out`) or in handle at `h` and the other as it is: still smooth when it was +/// and its handles still line up, a corner otherwise. +fn with_handle(a: Anchor, out: bool, h: Point) -> Anchor { + let (h_in, h_out) = if out { (a.h_in, h) } else { (h, a.h_out) }; + let kind = if a.kind == AnchorKind::Smooth { Anchor::with_handles(a.p, h_in, h_out).kind } else { AnchorKind::Corner }; + Anchor { p: a.p, h_in, h_out, kind } +} + /// Ramer–Douglas–Peucker polyline reduction. fn rdp(pts: &[Point], tol: f64) -> Vec { if pts.len() < 3 { @@ -136,7 +254,17 @@ fn rdp(pts: &[Point], tol: f64) -> Vec { keep[0] = true; keep[pts.len() - 1] = true; let mut stack = vec![(0, pts.len() - 1)]; + // Distances measured at most: lopsided splits (a shallow zigzag) make it quadratic. Past it, + // the points of the parts left are kept as they are. + let mut budget = pts.len().saturating_mul(64).saturating_add(1 << 20); while let Some((a, b)) = stack.pop() { + budget = match budget.checked_sub(b - a) { + Some(left) => left, + None => { + keep[a..=b].fill(true); + continue; + } + }; let (pa, pb) = (pts[a], pts[b]); let chord = pb - pa; let len = chord.hypot(); @@ -200,6 +328,23 @@ pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData { // Drop zero-length segments. segs.retain(|s| !(s.c.p0.distance(s.c.p3) < 1e-9 && s.c.p1.distance(s.c.p0) < 1e-9 && s.c.p2.distance(s.c.p0) < 1e-9)); let min_segs = if sp.closed { 2 } else { 1 }; + // Joints are merged along the path in one pass (a merged segment is tried with the one + // before it, then the next); the search below then only finds what a closed path's wrap + // leaves. (Merging at the first joint of a dense line one at a time shifts the rest each time.) + let total = segs.len(); + let mut merged = 0; + let mut done: Vec = Vec::with_capacity(total); + for s in segs.drain(..) { + done.push(s); + while done.len() >= 2 && total - merged > min_segs { + let n = done.len(); + let Some(m) = merge_pair(&done[n - 2], &done[n - 1], tol) else { break }; + done.truncate(n - 1); + done[n - 2] = m; + merged += 1; + } + } + segs = done; let mut changed = true; while changed && segs.len() > min_segs { changed = false; @@ -403,9 +548,12 @@ pub fn join(paths: &[PathData], tolerance: f64) -> PathData { return PathData::new(closed); } while open.len() > 1 { - // Find the closest pair of endpoints on different subpaths. + // Find the closest pair of endpoints on different subpaths. Past this many subpaths every + // pair at every merge is too much (cubic): the first one is joined to its nearest. + const SEARCH_ALL: usize = 200; + let first_only = open.len() > SEARCH_ALL; let mut best = (f64::INFINITY, 0, 0, false, false); - for i in 0..open.len() { + for i in 0..if first_only { 1 } else { open.len() } { for j in (i + 1)..open.len() { for ei in [false, true] { for ej in [false, true] { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 36e5a367f..12fafac2b 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -12,11 +12,12 @@ mod fit; mod offset; mod pathfinder; mod planar; +mod trap; pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all}; pub use edit::{ - AverageAxis, SIMPLIFY_LEVEL, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, remove_redundant_points, - simplify, simplify_level_tolerance, simplify_with, smooth, split_into_grid, + AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, + remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid, }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; @@ -24,6 +25,7 @@ pub use planar::{ BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap, }; +pub use trap::{TRAP_HEIGHT_WIDTH_RANGE, TRAP_MAX_WIDTH, TRAP_MIN_WIDTH, trap_band}; /// Errors from fallible operations. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index d3919f07b..593c6215c 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -614,11 +614,20 @@ fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec { }) .collect(); ts.sort_by(f64::total_cmp); - let start_is_junction = ts.first().is_some_and(|&t| t < 1e-9); + // Junctions are compared where they are, not by parameter: a shape's own anchor and + // a crossing computed a hair from it are one junction, and one within `reach` of an + // end of the segment is that end. Else a sliver piece is left between them (#3870). + let at = |t: f64| kurbo::ParamCurve::eval(&c, t); + let start_is_junction = ts.first().is_some_and(|&t| at(t).distance(c.p0) <= reach); let mut cuts: Vec = vec![0.0]; - cuts.extend(ts.into_iter().filter(|&t| t > 1e-9 && t < 1.0 - 1e-9)); + for t in ts { + let p = at(t); + let last = at(cuts.last().copied().unwrap_or(0.0)); + if p.distance(last) > reach && p.distance(c.p3) > reach { + cuts.push(t); + } + } cuts.push(1.0); - cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); for (k, w) in cuts.windows(2).enumerate() { let sub = c.subsegment(w[0]..w[1]); let seg = if line { Seg::line(sub.p0, sub.p3) } else { Seg { c: sub, line: false } }; diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index cc73f6e91..3e38e6ca1 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -1020,6 +1020,36 @@ pub(crate) fn map(shapes: &[Shape]) -> Option { } let close = |a: usize, b: usize| matches!((points.get(a), points.get(b)), (Some(p), Some(q)) if p.distance(*q) <= tol); let mut joined: Vec = (0..points.len()).collect(); + // Where two passes of one path cross another (an open path's way there and back, coincident + // outlines), the sweep can also join its two cuts, a rounding error apart, with a stub of that + // length: a speck of a piece, and a vertex twice over. Such stubs are no edges: their ends are + // one vertex. The sweep moves points by a few `eps` at most up and down its frame; along a path + // that runs nearly level in it (where the height a cut is found at pins its position down + // least) a cut can slide further, here up to a small fraction of the precision results are + // tidied to, below which no edge is drawn anyway. A vertex's cluster stays within those bounds + // as a whole: a run of short, nearly level segments (a finely flattened or traced path) is real + // geometry, not one stub after another, and must not collapse into one vertex. + let (stub_y, stub_x) = (4.0 * eps, (DEFAULT_PRECISION / 16.0).max(4.0 * eps)); + let mut extent: Vec = points.iter().map(|&p| kurbo::Rect::from_points(p, p)).collect(); + for (segs, [v0, v1]) in &pieces { + let Some(&p0) = points.get(*v0) else { continue }; + if !segs.iter().flat_map(|s| [s.c.p1, s.c.p2, s.c.p3]).all(|p| (p.y - p0.y).abs() <= stub_y && (p.x - p0.x).abs() <= stub_x) { + continue; + } + let (a, b) = (find(&mut joined, *v0), find(&mut joined, *v1)); + let (Some(&ra), Some(&rb)) = (extent.get(a), extent.get(b)) else { continue }; + let both = ra.union(rb); + if a == b || both.width() > stub_x || both.height() > stub_y { + continue; + } + let (root, child) = (a.min(b), a.max(b)); + if let Some(p) = joined.get_mut(child) { + *p = root; + } + if let Some(r) = extent.get_mut(root) { + *r = both; + } + } for (k, (_, [a, b])) in pieces.iter().enumerate() { let Some(&Some(m)) = mids.get(k) else { continue }; let (cx, cy) = cell_of(m); @@ -1948,6 +1978,102 @@ mod tests { (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p) } + /// Each piece cuts out of the input it is keyed by, losing just its own length (what erasing + /// it does), and none is a speck. + fn assert_pieces_cut_out(shapes: &[Shape], arr: &BuilderArrangement) { + for e in arr.edges.iter().chain(&arr.lines) { + let len = e.path.length(); + assert!(len > 1e-3, "a speck of a piece, keyed {}: {:?}", e.key, e.path); + let s = &shapes[e.key as usize]; + let cut = cut_out(&s.path, &e.path.subpaths[0], false, 0.05).unwrap_or_else(|| panic!("{:?} not on input {}", e.path, e.key)); + let lost = s.path.length() - cut.length(); + assert!((lost - len).abs() <= 1e-6 * s.path.length(), "input {} lost {lost} for a piece {len} long", e.key); + } + } + + #[test] + fn a_line_crossed_where_its_two_passes_part_is_cut_once() { + // The shrunk case of `pieces_cut_out_of_their_inputs`: the sweep cut the first line's way + // there and its way back by the third line a rounding error (1.2e-6) apart, joined by a + // stub. The line came back as a speck keyed by the third line and a piece running from + // the crossing out to (10, 70) and back to the other cut, which erasing took twice over. + let s = [line((10.0, 70.0), (80.0, 20.0), 0), line((10.0, 40.0), (70.0, 90.0), 0), line((20.0, 80.0), (20.0, 50.0), 0)]; + let arr = shape_builder(&s, true); + assert_pieces_cut_out(&s, &arr); + // The first line cut by both others, the other two once each. + let count = |k: u64| arr.lines.iter().filter(|l| l.key == k).count(); + assert_eq!((count(0), count(1), count(2)), (3, 2, 2), "{:?}", arr.lines); + let at = Point::new(20.0, 70.0 - 50.0 / 7.0); + for k in [0, 2] { + assert!(arr.lines.iter().filter(|l| l.key == k).all(|l| { + let (a, b) = ends(&l.path.subpaths[0]); + a.distance(b) > 1.0 && l.path.subpaths[0].anchors.len() == 2 + })); + assert_eq!( + arr.lines + .iter() + .filter(|l| l.key == k && [ends(&l.path.subpaths[0]).0, ends(&l.path.subpaths[0]).1].iter().any(|p| p.distance(at) < 1e-9)) + .count(), + 2 + ); + } + } + + #[test] + fn a_run_of_tiny_level_segments_is_not_contracted_as_stubs() { + // 2000 segments of 1e-4 (0.2 pt in all), nearly level: each one alone is as short as a + // stub the sweep leaves between two cuts, but chained they are real geometry, and the + // stub contraction must not pull them into one vertex. + let pts: Vec = (0..=2000).map(|k| Point::new(f64::from(k) * 1e-4, f64::from(k) * 1e-8)).collect(); + let run = Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0); + let whole = run.path.length(); + let s = [run, line((0.1003, -1.0), (0.1003, 1.0), 0)]; + let arr = shape_builder(&s, false); + let pieces: Vec<&Shape> = arr.lines.iter().filter(|l| l.key == 0).collect(); + assert_eq!(pieces.len(), 2, "cut once by the line: {}", pieces.len()); + let total: f64 = pieces.iter().map(|l| l.path.length()).sum(); + // Where the line crosses, one segment may be taken for a stub (it is as short as one), + // moving the cut by less than a stub's reach; the rest of the run stays. + assert!((total - whole).abs() <= DEFAULT_PRECISION / 16.0, "{total} of {whole}"); + for l in &pieces { + let (a, b) = ends(&l.path.subpaths[0]); + assert!(a.distance(b) > 0.09, "a piece collapsed: {a:?} {b:?}"); + } + assert_eq!(arr.lines.iter().filter(|l| l.key == 1).count(), 2); + } + + #[test] + fn a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing() { + // Lines every 0.25 or 0.5 pt (exact in binary) cut a crossing line or a circle where two + // passes of each line met it a rounding error apart, adding a speck and a piece there. + let hatch = |n: i32, step: f64, f: &dyn Fn(f64) -> Shape| -> Vec { (1..=n).map(|k| f(step * f64::from(k))).collect() }; + // Horizontal lines across a diagonal: each in two pieces, the diagonal in n + 1. + let mut s = vec![line((-3.0, -2.0), (33.0, 31.0), 0)]; + s.extend(hatch(119, 0.25, &|y| line((-5.0, y), (35.0, y), 0))); + let arr = shape_builder(&s, false); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.lines.iter().filter(|l| l.key == 0).count(), 120); + assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 2)); + assert_eq!(arr.lines.len(), 120 + 2 * 119); + // Sloping lines across a 45° one. + let mut s = vec![line((-3.0, -3.0), (33.0, 33.0), 0)]; + s.extend(hatch(59, 0.5, &|v| line((v - 40.0, -5.0), (v + 40.0, 35.0), 0))); + let arr = shape_builder(&s, false); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.lines.len(), 60 + 2 * 59, "{}", arr.lines.len()); + // Vertical lines across a circle: n + 1 strips, each line in three pieces, the circle's + // outline in two per line. + let mut s = vec![Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 30.0, 30.0)), FillRule::NonZero, 0)]; + s.extend(hatch(119, 0.25, &|x| line((x, -5.0), (x, 35.0), 0))); + let arr = shape_builder(&s, true); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.regions.len(), 120); + assert!(arr.regions.iter().all(|r| r.sources == [0])); + assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 3)); + assert_eq!(arr.lines.len(), 3 * 119); + assert_eq!(arr.edges.len(), 2 * 119); + } + #[test] fn shape_builder_edges_are_the_outlines_cut_where_paths_meet() { // Two overlapping squares: each outline in two pieces, cut where the other crosses it. diff --git a/crates/pathops/src/trap.rs b/crates/pathops/src/trap.rs new file mode 100644 index 000000000..ab5c31571 --- /dev/null +++ b/crates/pathops/src/trap.rs @@ -0,0 +1,124 @@ +//! The geometry of a trap (Pathfinder › Trap): the thin strip where one colour is spread into the +//! area of an adjoining colour, so a slight misregistration on press leaves no paper gap between +//! them. See `vectorcraft_effects::trap` for which colour spreads and how the strip is painted. + +use kurbo::Affine; +use vectorcraft_geom::{FillRule, PathData}; + +use crate::boolean::{BoolOp, try_boolean, try_normalize}; +use crate::offset::{Cap, Join, outline_stroke}; + +/// Largest trap width, in points, as the public Trap options allow (0.01–5000 pt). +pub const TRAP_MAX_WIDTH: f64 = 5000.0; +/// Smallest trap width, in points. +pub const TRAP_MIN_WIDTH: f64 = 0.01; +/// Height/Width range (horizontal edges' trap as a fraction of vertical edges'). +pub const TRAP_HEIGHT_WIDTH_RANGE: (f64, f64) = (0.01, 10.0); + +/// Does every anchor and handle of `p` have finite coordinates? +fn finite(p: &PathData) -> bool { + p.subpaths.iter().flat_map(|sp| sp.anchors.iter()).all(|a| [a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite())) +} + +/// The strip of `into` (with `into_rule`) within `width` of the edge of `spread` (with +/// `spread_rule`): where `spread`'s colour reaches when it grows by `width` across vertical edges +/// and by `width × height_width` across horizontal edges. Where the shapes overlap, the strip +/// also covers the overlap, so callers pass areas that don't overlap (the visible parts of two +/// objects). `precision` is the refit tolerance of the boolean that cuts the strip, in points. +/// +/// Empty when the shapes don't come within `width` of each other, when either is empty or has a +/// coordinate that isn't finite, or when `width` or `height_width` isn't a finite positive number. +/// `width` is capped at [`TRAP_MAX_WIDTH`] and `height_width` at [`TRAP_HEIGHT_WIDTH_RANGE`]. +pub fn trap_band( + spread: &PathData, + spread_rule: FillRule, + into: &PathData, + into_rule: FillRule, + width: f64, + height_width: f64, + precision: f64, +) -> PathData { + if !(width.is_finite() && width > 0.0 && height_width.is_finite() && height_width > 0.0) || spread.is_empty() || into.is_empty() { + return PathData::default(); + } + if !finite(spread) || !finite(into) { + return PathData::default(); + } + let w = width.clamp(TRAP_MIN_WIDTH, TRAP_MAX_WIDTH); + let r = height_width.clamp(TRAP_HEIGHT_WIDTH_RANGE.0, TRAP_HEIGHT_WIDTH_RANGE.1); + let precision = if precision.is_finite() && precision > 0.0 { precision.min(10.0) } else { crate::boolean::DEFAULT_PRECISION }; + // Quick reject: bounds farther apart than the trap reaches. + let (Some(sb), Some(ib)) = (spread.bounds(), into.bounds()) else { return PathData::default() }; + let reach = sb.inflate(w, w * r); + if reach.x1 < ib.x0 || ib.x1 < reach.x0 || reach.y1 < ib.y0 || ib.y1 < reach.y0 { + return PathData::default(); + } + // Grow uniformly in a space squashed vertically by `r`, then stretch back: horizontal edges' + // strip comes out `r` times as tall as vertical edges' is wide. + let Ok(outline) = try_normalize(spread, spread_rule) else { return PathData::default() }; + let squash = Affine::scale_non_uniform(1.0, 1.0 / r); + let ring = outline_stroke(&outline.transformed(squash), 2.0 * w, Cap::Butt, Join::Miter, 4.0); + if ring.is_empty() { + return PathData::default(); + } + let ring = ring.transformed(Affine::scale_non_uniform(1.0, r)); + try_boolean(&ring, FillRule::NonZero, into, into_rule, BoolOp::Intersect, precision).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use vectorcraft_geom::Rect; + use vectorcraft_geom::shapes::rectangle; + + use super::*; + + fn bounds(p: &PathData) -> Rect { + p.bounds().unwrap_or_default() + } + + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.02 + } + + #[test] + fn abutting_rectangles_trap_along_their_shared_edge() { + let light = rectangle(Rect::new(0.0, 0.0, 50.0, 40.0)); + let dark = rectangle(Rect::new(50.0, 0.0, 100.0, 40.0)); + let band = trap_band(&light, FillRule::NonZero, &dark, FillRule::NonZero, 2.0, 1.0, 0.01); + let b = bounds(&band); + assert!(near(b.x0, 50.0) && near(b.x1, 52.0) && near(b.y0, 0.0) && near(b.y1, 40.0), "{b:?}"); + assert!(near(crate::area(&band, FillRule::NonZero), 80.0)); + } + + #[test] + fn height_width_scales_the_trap_on_horizontal_edges() { + let light = rectangle(Rect::new(0.0, 0.0, 40.0, 50.0)); + let dark = rectangle(Rect::new(0.0, 50.0, 40.0, 100.0)); + let band = trap_band(&light, FillRule::NonZero, &dark, FillRule::NonZero, 2.0, 2.0, 0.01); + let b = bounds(&band); + assert!(near(b.y0, 50.0) && near(b.y1, 54.0), "a horizontal edge's trap is twice the width: {b:?}"); + } + + #[test] + fn far_apart_shapes_and_bad_numbers_give_nothing() { + let a = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let b = rectangle(Rect::new(30.0, 0.0, 40.0, 10.0)); + assert!(trap_band(&a, FillRule::NonZero, &b, FillRule::NonZero, 2.0, 1.0, 0.01).is_empty()); + let c = rectangle(Rect::new(10.0, 0.0, 20.0, 10.0)); + for (w, r) in [(f64::NAN, 1.0), (f64::INFINITY, 1.0), (-1.0, 1.0), (0.0, 1.0), (1.0, f64::NAN), (1.0, 0.0)] { + assert!(trap_band(&a, FillRule::NonZero, &c, FillRule::NonZero, w, r, 0.01).is_empty(), "{w} {r}"); + } + let mut bad = a.clone(); + bad.subpaths[0].anchors[0].p.x = f64::NAN; + assert!(trap_band(&bad, FillRule::NonZero, &c, FillRule::NonZero, 1.0, 1.0, 0.01).is_empty()); + } + + #[test] + fn a_huge_width_is_capped_and_stays_inside_the_darker_shape() { + let a = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let c = rectangle(Rect::new(10.0, 0.0, 20.0, 10.0)); + let band = trap_band(&a, FillRule::NonZero, &c, FillRule::NonZero, 1e300, 1e300, f64::NAN); + let b = bounds(&band); + assert!(near(b.x0, 10.0) && near(b.x1, 20.0), "the whole darker square at most: {b:?}"); + } +} diff --git a/crates/pathops/tests/it/main.rs b/crates/pathops/tests/it/main.rs new file mode 100644 index 000000000..3f565e040 --- /dev/null +++ b/crates/pathops/tests/it/main.rs @@ -0,0 +1,12 @@ +//! 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 = "../common/mod.rs"] +mod common; + +mod ops; +mod pathfinder_pinned; +mod prop_pathops; +mod props; +mod regressions; diff --git a/crates/pathops/tests/ops.rs b/crates/pathops/tests/it/ops.rs similarity index 97% rename from crates/pathops/tests/ops.rs rename to crates/pathops/tests/it/ops.rs index 871f531ff..ff8e6aa76 100644 --- a/crates/pathops/tests/ops.rs +++ b/crates/pathops/tests/it/ops.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::f64::consts::PI; use kurbo::{ParamCurve, Point, Rect, Shape as _}; @@ -219,6 +217,17 @@ fn pf_outline_edges() { assert!(close(len, 80.0, 1e-9)); } +/// #3870: a circle whose anchors sit on the square's edge it crosses gives the four edges of the +/// arrangement, meeting at those anchors, and no sliver between an anchor and the crossing +/// computed a hair from it. +#[test] +fn pf_outline_has_no_sliver_where_an_anchor_is_the_crossing() { + let s = vec![shape(rect(0.0, 0.0, 100.0, 100.0), 1), shape(circle(100.0, 100.0, 50.0), 2)]; + let r = pathfinder(PathfinderOp::Outline, &s); + assert_eq!(r.len(), 4, "{r:?}"); + assert!(r.iter().all(|s| s.path.length() > 1.0), "no sliver: {r:?}"); +} + #[test] fn pf_outline_dedups_shared_edges() { // Two squares sharing an edge exactly. diff --git a/crates/pathops/tests/pathfinder_pinned.rs b/crates/pathops/tests/it/pathfinder_pinned.rs similarity index 98% rename from crates/pathops/tests/pathfinder_pinned.rs rename to crates/pathops/tests/it/pathfinder_pinned.rs index 5e9f27a3d..a6e987d27 100644 --- a/crates/pathops/tests/pathfinder_pinned.rs +++ b/crates/pathops/tests/it/pathfinder_pinned.rs @@ -1,7 +1,6 @@ //! The exact output of every Pathfinder operation, pinned so that work on the windings and the //! coverage tests can't change it: overlapping ellipses, rectangles and stars (even-odd and //! non-zero, repeated keys for Merge) at a few counts. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath, shapes}; use vectorcraft_pathops::{PathfinderOp, Shape, pathfinder}; diff --git a/crates/pathops/tests/prop_pathops.rs b/crates/pathops/tests/it/prop_pathops.rs similarity index 98% rename from crates/pathops/tests/prop_pathops.rs rename to crates/pathops/tests/it/prop_pathops.rs index 82feae9f9..995f86975 100644 --- a/crates/pathops/tests/prop_pathops.rs +++ b/crates/pathops/tests/it/prop_pathops.rs @@ -1,9 +1,7 @@ //! Path-operation properties on concave polygons, ellipses and random smooth curves (complements //! `props.rs`, which uses rectangles and circles). -// 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)] -mod common; +use crate::common; use proptest::prelude::*; use vectorcraft_geom::{FillRule, PathData, Point, Rect}; diff --git a/crates/pathops/tests/props.rs b/crates/pathops/tests/it/props.rs similarity index 96% rename from crates/pathops/tests/props.rs rename to crates/pathops/tests/it/props.rs index f09e96f0f..b5adcb7cc 100644 --- a/crates/pathops/tests/props.rs +++ b/crates/pathops/tests/it/props.rs @@ -1,6 +1,4 @@ -// 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)] -mod common; +use crate::common; use std::f64::consts::PI; diff --git a/crates/pathops/tests/regressions.rs b/crates/pathops/tests/it/regressions.rs similarity index 100% rename from crates/pathops/tests/regressions.rs rename to crates/pathops/tests/it/regressions.rs diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs index 979a2de1a..9b5600829 100644 --- a/crates/pdf/src/editing.rs +++ b/crates/pdf/src/editing.rs @@ -137,7 +137,18 @@ fn page_hash(pdf: &Pdf) -> String { for page in pdf.pages().iter() { let b = page.media_box(); h = fnv(h, format!("[{} {} {} {}]", b.x0, b.y0, b.x1, b.y1).as_bytes()); - h = fnv(h, page.page_stream().unwrap_or_default()); + // Decode into a temporary rather than through `Page::page_stream`, which keeps every + // page's copy for as long as the PDF lives, even when pages share one stream. + let contents = page.raw(); + if let Some(stream) = contents.get::>(b"Contents") { + h = fnv(h, stream.decoded().as_deref().unwrap_or_default()); + } else if let Some(array) = contents.get::>(b"Contents") { + for stream in array.iter::>() { + if let Ok(data) = stream.decoded() { + h = fnv(fnv(h, &data), b" "); + } + } + } } format!("{h:016x}") } diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index 39a9ff48b..705ba0293 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -113,6 +113,13 @@ impl SecuritySettings { standard.label() ))); } + // Revision 2 has one printing bit: it can't say "low resolution only". + if revision(c) == 2 && self.printing == Printing::Low && !self.permissions_password.is_empty() { + return Err(PdfError::BadSetting(format!( + "{} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing", + c.label() + ))); + } if self.open_password == self.permissions_password { return Err(PdfError::BadSetting("the open and permissions passwords must differ".into())); } @@ -146,6 +153,12 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec { if s.protected() && s.plaintext_metadata && revision(set.compatibility) <= 3 { out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label())); } + if s.protected() && set.compatibility == Compatibility::Pdf13 { + out.push(format!( + "{} encrypts with a 40-bit key, which can be searched in minutes: choose PDF 1.6 or later for stronger protection", + set.compatibility.label() + )); + } out } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index abae9c6c8..1854db945 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -38,7 +38,7 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { /// Like [`export`], also returning warnings: options accepted but not applied yet (see /// [`crate::PdfSettings::warnings`]) and features approximated or dropped. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { - write::guard(|| leaving_out_undecodable(|left_out| export_with_report_inner(doc, opts, left_out))) + write::guard(|| leaving_out_undecodable(doc, |left_out| export_with_report_inner(doc, opts, left_out))) } /// Run `write` (a whole PDF written by a [`Writer`]), and run it again each time it fails on an @@ -46,12 +46,21 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result(mut write: impl FnMut(&mut HashSet) -> Result) -> Result { +pub(crate) fn leaving_out_undecodable(doc: &Document, mut write: impl FnMut(&mut HashSet) -> Result) -> Result { + // The most runs repeated: each is a whole export, and a document of many images that don't + // decode would make them quadratic. Past it, one last run leaves out every image of `doc`, so + // the export still finishes (the images left out are reported). + const MAX_RETRIES: usize = 16; let mut left_out = HashSet::new(); + let mut retries = 0; loop { let known = left_out.len(); match write(&mut left_out) { - Err(_) if left_out.len() > known => {} + Err(_) if left_out.len() > known && retries < MAX_RETRIES => retries += 1, + Err(_) if left_out.len() > known && retries == MAX_RETRIES => { + left_out.extend(doc.images.keys().cloned()); + retries += 1; + } done => return done, } } @@ -612,6 +621,8 @@ pub(crate) struct Exporter<'a> { /// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in /// text…): deeper ones are left out. inline_depth: u32, + /// Tiling patterns being drawn inside tiling patterns ([`Self::tiling`]). + pattern_depth: u32, /// Keys of the images left out because the writer couldn't decode them /// ([`leaving_out_undecodable`]). pub left_out: HashSet, @@ -620,6 +631,9 @@ pub(crate) struct Exporter<'a> { /// How deep inline graphics nest before they are left out. const MAX_INLINE_DEPTH: u32 = 4; +/// How deep tiling patterns nest: deeper pattern fills are written as their tiles. +const MAX_PATTERN_DEPTH: u32 = 4; + /// The warning for an image left out because it can't be decoded: the note raster exports give /// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; @@ -655,6 +669,7 @@ impl<'a> Exporter<'a> { overprinted: false, invisible: false, inline_depth: 0, + pattern_depth: 0, left_out: HashSet::new(), } } @@ -1295,9 +1310,9 @@ impl Exporter<'_> { /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a - /// pattern's tile instances covering it, or an image of a gradient drawn as pixels placed on - /// `bounds` (the painted object's box, as on the canvas), clipped to it. False (nothing drawn) - /// for other paints. + /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image + /// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the + /// canvas), clipped to it. False (nothing drawn) for other paints. #[allow(clippy::too_many_arguments)] fn area( &mut self, @@ -1314,26 +1329,40 @@ impl Exporter<'_> { return false; } let (opacity, mode) = transparency; + let tiling = match p { + Paint::Pattern { pattern, xf } => self.tiling(s, pattern, *xf), + _ => None, + }; let mut pushes = 0; if mode != BlendMode::Normal { s.push_blend_mode(blend(mode)); pushes += 1; } - s.push_clip_path(clip, &rule); - pushes += 1; - if opacity < 1.0 { - s.push_opacity(norm(opacity)); + if let Some(paint) = tiling { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule })); + s.draw_path(clip); + s.set_fill(None); + } else { + s.push_clip_path(clip, &rule); pushes += 1; - } - let doc = self.doc; - match p { - Paint::Pattern { pattern, xf } => { - for inst in doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default() { - self.node(s, &inst, area, true); + if opacity < 1.0 { + s.push_opacity(norm(opacity)); + pushes += 1; + } + let doc = self.doc; + match p { + Paint::Pattern { pattern, xf } => { + if let Some(def) = doc.pattern(pattern) { + self.warn("pattern fills nested more than 4 patterns deep, or with tiles too large for PDF, are written as their tiles"); + for inst in def.instances_in(*xf, area) { + self.node(s, &inst, area, true); + } + } } + Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill), + _ => {} } - Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill), - _ => {} } for _ in 0..pushes { s.pop(); @@ -1341,6 +1370,43 @@ impl Exporter<'_> { true } + /// Pattern swatch `name` placed by `at` (pattern space → the current space) as a PDF tiling + /// pattern (ISO 32000-1, 8.7.3.1): one period of the tiling ([`vectorcraft_doc::PatternDef::period`], + /// what the canvas repeats too) with every tile instance reaching into it, in paint order, so + /// brick and hex tilings and overlapping tiles repeat as they are; clipped to the period when + /// art reaches out of it. `None` for a pattern nested deeper than [`MAX_PATTERN_DEPTH`], a period + /// or placement beyond [`MAX_COORD`] or a placement that flattens the tiles. + fn tiling(&mut self, s: &mut Surface, name: &str, at: Affine) -> Option { + let doc = self.doc; + let def = doc.pattern(name)?; + let (pw, ph) = def.period(); + if self.pattern_depth >= MAX_PATTERN_DEPTH || !in_range(&[pw, ph]) || !in_range(&at.as_coeffs()) || at.determinant().abs() <= 1e-12 { + return None; + } + let cell = Rect::new(0.0, 0.0, pw, ph); + let tiles = def.instances_in(Affine::IDENTITY, cell); + let mut sb = s.stream_builder(); + let mut cs = sb.surface(); + let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b)); + let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten(); + if let Some(c) = &clip { + cs.push_clip_path(c, &krilla::paint::FillRule::NonZero); + } + // Pattern art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); + self.pattern_depth += 1; + for t in &tiles { + self.node(&mut cs, t, cell, true); + } + self.pattern_depth -= 1; + self.knockout = knockout; + if clip.is_some() { + cs.pop(); + } + cs.finish(); + Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) + } + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most @@ -1849,11 +1915,12 @@ impl Exporter<'_> { font } - /// Image `key` placed `size` points wide and high, resampled and compressed as the Compression + /// Image `im` placed `size` points wide and high, resampled and compressed as the Compression /// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images); /// cached by key (and size when images are resampled). `None`, with [`UNDECODED`] among the /// warnings, for an image that is missing, can't be decoded or is in [`Self::left_out`]. - fn load_image(&mut self, key: &str, size: (f64, f64)) -> Option { + fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64)) -> Option { + let key = im.key.as_str(); let c = self.compression; let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; @@ -1863,7 +1930,7 @@ impl Exporter<'_> { let interpolate = self.interpolate; let doc = self.doc; let out = self.out.clone(); - let img = doc.images.get(key).filter(|_| !self.left_out.contains(key)).and_then(|blob| { + let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { self.rgb_in_cmyk.images.insert(key.to_string()); @@ -1904,8 +1971,8 @@ impl Exporter<'_> { return i.clone(); } let doc = self.doc; - let img = doc.images.get(&im.key).filter(|b| b.is_cmyk()).and_then(|blob| { - let alpha = crate::images::mask_alpha(doc.images.get(&mi.key)?, im.width, im.height, m.clip)?; + let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { + let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha)); if let (Some(w), Some(_)) = (r.warning, &r.image) { self.warn(w); @@ -1922,7 +1989,7 @@ impl Exporter<'_> { if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { return; } - let Some(img) = img.or_else(|| self.load_image(&im.key, placed_size(im))) else { return }; + let Some(img) = img.or_else(|| self.load_image(im, placed_size(im))) else { return }; let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 35b16a277..1c0937324 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -392,6 +392,8 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); let page_layer = |b: &mut Builder<'_>, art: Vec>, shown: bool| { @@ -481,6 +483,10 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p let list = b.missing_fonts.join(", "); b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}")); } + // Before anything walks the tree by recursion (`fix_next_id` does): nested clips can be deeper + // than the stack goes. + let mut depth_notes = vec![]; + doc.limit_depth_noting(&mut depth_notes); doc.fix_next_id(); doc.reserve_ids(b.next); @@ -492,7 +498,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p } } } - doc.limit_depth_noting(&mut warnings); + warnings.extend(depth_notes); let user_unit = picked.first().and_then(|&n| pages.get(n)).map_or(1.0, |page| f64::from(page.user_unit())); Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit }) } @@ -980,12 +986,20 @@ fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> Stro kurbo::Join::Bevel => LineJoin::Bevel, }; st.miter_limit = p.miter_limit as f64; - let dash = Dash { pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(), offset: p.dash_offset as f64 * scale, align_corners: false }; + // (Every stroke keeps its own copy: a dash array has a few entries, and one with thousands is cut.) + let dash = Dash { + pattern: p.dash_array.iter().take(MAX_DASH_ENTRIES).map(|v| *v as f64 * scale).collect(), + offset: p.dash_offset as f64 * scale, + align_corners: false, + }; // An invalid dash array (a negative value, or all zeros) strokes solid. st.dash = dash.is_dashed().then_some(dash); st } +/// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). +const MAX_DASH_ENTRIES: usize = 64; + pub(crate) fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } @@ -1567,7 +1581,7 @@ impl<'p> Builder<'p> { let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); let art = Node::new( self.id(), - NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default(), pixels: None })), ); Some(OpacityMask::new(art, true)) } @@ -1587,8 +1601,10 @@ impl<'p> Builder<'p> { (k, w, h) }; let id = self.id(); - let mut n = - Node::new(id, NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); + let mut n = Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ); n.extras_mut().mask = mask.map(Box::new); self.push_node(n); } diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index 2bdafbadd..ac5b1580e 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -362,7 +362,8 @@ impl<'p> Colors<'p> { let alt = alt_model(&it.next::>()?); let tint = Function::new(&it.next::>()?)?; let key = debug_text(&tint)?; - if names.is_empty() || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { + // (PDF's limit is 32 colorants; each gets a vector of that length below.) + if names.is_empty() || names.len() > MAX_COLORANTS || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { return None; } let colorants = names @@ -521,3 +522,6 @@ fn alt_color(alt: Alt, v: &[f32]) -> Option { Alt::Lab => Color::lab(round3(c(0)?.clamp(0.0, 100.0)), round3(c(1)?.clamp(-128.0, 127.0)), round3(c(2)?.clamp(-128.0, 127.0))), }) } + +/// The most colorants of a DeviceN space read. +const MAX_COLORANTS: usize = 64; diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 613bb33e7..694cc35c5 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -28,6 +28,9 @@ use crate::import_color::ink_names; /// The interpreter's limit on nested form XObjects. const MAX_DEPTH: u32 = 50; + +/// The most graphics states saved at once (PDF's own limit for nesting is 28; files nest far deeper). +const MAX_STATES: usize = 65_536; /// The most content operators walked per page (a guard against forms drawn within each other /// many times over): the art past it can't be told apart by group. A drawing of a million paths /// takes a few million; the walk reads about 15 million a second, twice as fast as the @@ -464,6 +467,8 @@ struct Walker<'w, 'p> { /// The saved graphics states' overprint parameters and inks, the current one last, pushed and /// popped as the interpreter does its states. states: Vec, + /// The `q`s past [`MAX_STATES`] whose `Q` has not come. + unkept: usize, /// The text shown since the last painted path. text: TextOverprint, /// The inks of the text shown since the last painted path: of its fills, of its strokes. @@ -513,12 +518,20 @@ impl Walker<'_, '_> { } fn save(&mut self) { + // Past this many nested states the new ones are not kept (a stream of `q` is cheap to write + // and holds a state each); their `Q`s are counted off first. + if self.states.len() >= MAX_STATES { + self.unkept = self.unkept.saturating_add(1); + return; + } self.states.push(self.state()); } /// The interpreter never pops its first state. fn restore(&mut self) { - if self.states.len() > 1 { + if self.unkept > 0 { + self.unkept -= 1; + } else if self.states.len() > 1 { self.states.pop(); } } @@ -1028,6 +1041,7 @@ fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: & out: Scan::default(), hidden_form: false, states: vec![State::default()], + unkept: 0, text: TextOverprint::None, text_inks: [RunInk::None; 2], ink_index: HashMap::new(), diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 7117a14be..4eca5d11a 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -166,6 +166,9 @@ pub(crate) struct TextLine { /// Runs of glyphs drawn alike: their look, how far the file raised them off the line's /// baseline (points, up; text rise, `Ts`), their text. runs: Vec<(Look, f64, String)>, + /// The bytes of text the horizontal glyphs added to `runs` so far (counted as they come: a + /// sum over the runs for each glyph is quadratic when every glyph is a run). + text_bytes: usize, /// Where the file put each character of the runs' text, in order: the first character of each /// glyph has its glyph's [`Slot`]; a space read from a gap and the other characters of a /// glyph of several have none. @@ -227,6 +230,7 @@ impl TextLine { last_size: at.size, baseline: vec![], runs: vec![], + text_bytes: 0, slots: vec![], upright: None, column: false, @@ -292,7 +296,7 @@ impl TextLine { if self.column || self.upright.is_some() { return false; } - let bytes: usize = self.runs.iter().map(|(_, _, t)| t.len()).sum(); + let bytes = self.text_bytes; let glyph = self.baseline.len(); let curved = self.curved(); let mut shift = 0.0; @@ -350,6 +354,7 @@ impl TextLine { } else { self.runs.push((look.clone(), 0.0, text.to_string())); } + self.text_bytes = self.text_bytes.saturating_add(text.len()); let one = text.chars().count() == 1; self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) })); self.slots.extend(text.chars().skip(1).map(|_| None)); diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index d207fac8f..56c8a2525 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -221,6 +221,10 @@ impl<'a> Job<'a> { if printed.is_empty() { return Err(PdfError::BadSetting("output.inks: no ink is set to print".into())); } + // Counted before the sheets are made: layouts times inks can be far more than the limit. + if layouts.len().saturating_mul(printed.len()).saturating_mul(set.copies as usize) > MAX_PAGES { + return Err(PdfError::BadSetting(format!("the job would print more than {MAX_PAGES} pages"))); + } let sheets: Vec<(usize, Option)> = (0..layouts.len()).flat_map(|l| printed.iter().map(move |i| (l, *i))).collect(); if sheets.len().saturating_mul(set.copies as usize) > MAX_PAGES { return Err(PdfError::BadSetting(format!("the job would print more than {MAX_PAGES} pages"))); @@ -392,7 +396,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, /// Print `doc` to a PDF as `opts` say. pub fn print(doc: &Document, opts: &PrintOptions) -> Result { - craft_pdf::write::guard(|| leaving_out_undecodable(|left_out| print_inner(doc, opts, left_out))) + craft_pdf::write::guard(|| leaving_out_undecodable(doc, |left_out| print_inner(doc, opts, left_out))) } fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet) -> Result { diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs index 7ef4e6834..25f0c1973 100644 --- a/crates/pdf/src/streams.rs +++ b/crates/pdf/src/streams.rs @@ -271,7 +271,12 @@ fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool { /// here). pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { let find = |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at); - let mut from = 0; + // A walk back through a dictionary stops at `dicts_from`, the end of the last keyword walked + // back from, so the scan reads each byte a bounded number of times (#3586): before, every + // keyword walked back up to 64 KiB through all the dictionaries before it. + let (mut from, mut dicts_from) = (0, 0); + // The first `endstream` at or after the last place it was looked for (`None`: there is none). + let mut endstream: Option> = None; while let Some(at) = find(from, b"stream") { from = at + 6; if at >= 3 && pdf.get(at - 3..at) == Some(b"end") { @@ -282,8 +287,10 @@ pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { if close < 2 || pdf.get(close - 2..close) != Some(b">>") { continue; } + let floor = close.saturating_sub(1 << 16).max(dicts_from); + dicts_from = from; let (mut depth, mut open, mut i) = (0usize, None, close); - while i >= 2 && close - i < 1 << 16 { + while i >= floor + 2 { match pdf.get(i - 2..i) { Some(b">>") => (depth, i) = (depth + 1, i - 2), Some(b"<<") => { @@ -308,12 +315,16 @@ pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { let start = from + usize::from(pdf.get(from) == Some(&b'\r')) + usize::from(pdf.get(from + usize::from(pdf.get(from) == Some(&b'\r'))) == Some(&b'\n')); - let end = dict - .get::(b"Length") - .and_then(|n| start.checked_add(n)) - .filter(|e| *e <= pdf.len()) - .or_else(|| find(start, b"endstream")) - .unwrap_or(pdf.len()); + let length = dict.get::(b"Length").and_then(|n| start.checked_add(n)).filter(|e| *e <= pdf.len()); + let end = match length { + Some(end) => end, + None => { + if endstream.is_none_or(|e| e.is_some_and(|e| e < start)) { + endstream = Some(find(start, b"endstream")); + } + endstream.flatten().unwrap_or(pdf.len()) + } + }; if matches!(decode(pdf.get(start..end).unwrap_or_default(), &dict, false), Err(TooBig)) { return true; } @@ -463,5 +474,22 @@ mod tests { ] { assert!(!unsafe_file_streams(&fine)); } + // A bad stream after others is still found: a walk back stops at the previous keyword, not before its own `<<`. + let fine = stream("/Type /XRef /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 5 >>", b"x"); + let bad = stream("/Type /XRef /Filter /FlateDecode /DecodeParms 9 0 R", b"x"); + assert!(unsafe_file_streams(&[fine.as_slice(), b"<< >>stream x endstream", &bad].concat())); + } + + #[test] + fn a_file_dense_with_stream_keywords_is_scanned_in_linear_time() { + // #3586: each `stream` keyword walked back up to 64 KiB, through every dictionary before + // it, so 1 MiB of keywords took minutes. Now the whole scan takes milliseconds. + for unit in [&b"<> >>stream"[..], b">>stream", b"<>stream"] { + let pdf = unit.repeat((1 << 20) / unit.len()); + let t = std::time::Instant::now(); + assert!(!unsafe_file_streams(&pdf)); + let took = t.elapsed(); + assert!(took < std::time::Duration::from_secs(5), "{} took {took:?}", String::from_utf8_lossy(unit)); + } } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b0e7813e8..6fa4da12a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -82,6 +82,8 @@ fn one_page_per_artboard_with_sizes() { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); d.artboards.push(Artboard { @@ -91,6 +93,8 @@ fn one_page_per_artboard_with_sizes() { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); let bytes = export(&d, &PdfOptions::default()).unwrap(); @@ -109,6 +113,8 @@ fn artboard_range_and_bad_index() { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); let bytes = export(&d, &PdfOptions { artboards: Some(vec![1]), ..Default::default() }).unwrap(); @@ -379,6 +385,7 @@ fn art_reaching_beyond_a_billion_points_is_left_out() { xf: Affine::new([5.5e8, 0.0, 0.0, 10.0, far, 60.0]), link: None, placement: Default::default(), + pixels: None, }; for (what, n) in [ ("path", rect_node(Rect::new(far, 60.0, 90.0, 90.0), Color::BLACK)), @@ -554,6 +561,7 @@ fn image_roundtrip() { xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); let out = roundtrip(&d); @@ -595,7 +603,7 @@ fn an_image_whose_pixels_cant_be_decoded_is_left_out_with_one_note() { for (i, (key, blob)) in blobs.into_iter().enumerate() { d.images.insert(key.into(), blob); let xf = Affine::translate((20.0, 20.0 + 50.0 * i as f64)) * Affine::scale(10.0); - let im = ImageObject { key: key.into(), width: 4, height: 2, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 4, height: 2, xf, link: None, placement: Default::default(), pixels: None }; add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); } add(&mut d, rect_node(Rect::new(100.0, 100.0, 150.0, 150.0), Color::BLACK)); @@ -628,6 +636,7 @@ fn a_group_transform_effect_exports_its_image_copies() { xf: Affine::translate((20.0, 30.0)) * Affine::scale(5.0), link: None, placement: Default::default(), + pixels: None, }; let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))); let mut square = rect_node(Rect::new(20.0, 60.0, 30.0, 70.0), Color::BLACK); @@ -756,6 +765,8 @@ fn import_max_pages_and_multi_page() { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK)); diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 67e67f26c..e7c08f5aa 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -306,7 +306,15 @@ fn all_images(d: &Document) -> Vec<&ImageBlob> { fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); d.images.insert("img".into(), blob); - let image = ImageObject { key: "img".into(), width: w, height: h, xf: Affine::scale(40.0 / w as f64), link: None, placement: Default::default() }; + let image = ImageObject { + key: "img".into(), + width: w, + height: h, + xf: Affine::scale(40.0 / w as f64), + link: None, + placement: Default::default(), + pixels: None, + }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); @@ -383,6 +391,7 @@ fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec, mime: &str) -> Document { let xf = Affine::scale(72.0 / 600.0); let n = Node::new( d.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key: "img".into(), + width: 600, + height: 600, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs index 95b8439f2..26d65fd19 100644 --- a/crates/pdf/src/tests_output.rs +++ b/crates/pdf/src/tests_output.rs @@ -197,7 +197,8 @@ fn png(rgb: [u8; 3]) -> Vec { fn image_doc(bytes: Vec) -> Document { let mut d = Document::new(100.0, 100.0); d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); - let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() }; + let image = + ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default(), pixels: None }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests_pdfx.rs index 400662b76..dbc221e0d 100644 --- a/crates/pdf/src/tests_pdfx.rs +++ b/crates/pdf/src/tests_pdfx.rs @@ -125,6 +125,7 @@ fn pdfx_1a_files_have_no_rgb() { xf: Affine::translate((150.0, 50.0)) * Affine::scale(5.0), link: None, placement: Default::default(), + pixels: None, }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_print.rs b/crates/pdf/src/tests_print.rs index 72a314db9..d969dcacc 100644 --- a/crates/pdf/src/tests_print.rs +++ b/crates/pdf/src/tests_print.rs @@ -30,6 +30,8 @@ fn artboard(id: u32, x: f64) -> Artboard { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, } } diff --git a/crates/pdf/src/tests_webview.rs b/crates/pdf/src/tests_webview.rs index 48485e575..680845722 100644 --- a/crates/pdf/src/tests_webview.rs +++ b/crates/pdf/src/tests_webview.rs @@ -28,6 +28,8 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, color: None, }); } @@ -49,7 +51,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { .unwrap(); if image && (i > 0 || on_first) { let xf = Affine::translate((x + 60.0, 60.0)) * Affine::scale(4.0); - let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default() }; + let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default(), pixels: None }; let id = d.alloc_id(); d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(img)))).unwrap(); } diff --git a/crates/pdf/tests/editing_memory.rs b/crates/pdf/tests/editing_memory.rs new file mode 100644 index 000000000..ee8e5d916 --- /dev/null +++ b/crates/pdf/tests/editing_memory.rs @@ -0,0 +1,79 @@ +//! Opening a PDF whose pages share one large content stream, with an editing file naming a page +//! hash, keeps a working set that doesn't grow with the page count. One test in its own process, +//! so the allocator's peak is this test's. +// 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)] +// A counting allocator forwards to the system one. +#![allow(unsafe_code)] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + +use vectorcraft_pdf::{EDITING_FILE, editing_with}; + +static LIVE: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +struct Counting; + +fn grew(n: usize) { + PEAK.fetch_max(LIVE.fetch_add(n, Relaxed) + n, Relaxed); +} + +// SAFETY: every call is forwarded to the system allocator unchanged. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + grew(l.size()); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + LIVE.fetch_sub(l.size(), Relaxed); + unsafe { System.dealloc(p, l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + LIVE.fetch_sub(l.size(), Relaxed); + grew(new); + unsafe { System.realloc(p, l, new) } + } +} + +#[global_allocator] +static ALLOC: Counting = Counting; + +fn raw_pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for at in offsets { + out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +#[test] +fn pages_sharing_one_stream_are_hashed_without_keeping_a_copy_each() { + const PAGES: usize = 12; + const STREAM: usize = 4 << 20; + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {PAGES} >>", (0..PAGES).map(|i| format!("{} 0 R ", i + 7)).collect::()), + "<< /Names [(a) 4 0 R] >>".to_string(), + format!("<< /Type /Filespec /UF ({EDITING_FILE}) /Desc (VectorCraft editing data; page hash 0000000000000000) /EF << /F 5 0 R >> >>"), + "<< /Length 5 >>\nstream\nhello\nendstream".to_string(), + format!("<< /Length {STREAM} >>\nstream\n{}\nendstream", "q ".repeat(STREAM / 2)), + ]; + objects.extend((0..PAGES).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 6 0 R >>".to_string())); + let bytes = raw_pdf(&objects); + let base = LIVE.load(Relaxed); + PEAK.store(base, Relaxed); + let e = editing_with(&bytes, None).unwrap(); + let extra = PEAK.load(Relaxed) - base; + assert_eq!(e.data, b"hello"); + assert!(extra < 4 * STREAM, "peak {extra} bytes above the input: each page kept its own decoded copy"); +} diff --git a/crates/render/src/dither.rs b/crates/render/src/dither.rs index 466eb93e4..9d214616b 100644 --- a/crates/render/src/dither.rs +++ b/crates/render/src/dither.rs @@ -20,7 +20,6 @@ use vectorcraft_geom::{Affine, Rect}; use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; -use crate::freeform::hash_color; use crate::ink::Ink; use crate::lru::{self, FrameCache}; @@ -60,7 +59,7 @@ fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: &[Range; 2], (gr.kind as u8, gr.dither, gr.method as u8).hash(&mut h); for s in &gr.stops { (s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h); - hash_color(&s.color, &mut h); + s.color.key().hash(&mut h); } let geom = g.resolve(b); (geom.start.x.to_bits(), geom.start.y.to_bits(), geom.end.x.to_bits(), geom.end.y.to_bits(), geom.aspect.to_bits()).hash(&mut h); diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs index 9c5061f1d..ec04c9f46 100644 --- a/crates/render/src/encode/quantize.rs +++ b/crates/render/src/encode/quantize.rs @@ -197,7 +197,9 @@ pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, } let weights = o.reduction.weights(); let dither = if exact { Dither::None } else { o.dither }; - let mut indices = map(&colors, width as usize, &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); + // (A width past the pixels there are would size the error rows from a number nothing checked.) + let mut indices = + map(&colors, (width as usize).min(colors.len()), &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); // Drop the entries no pixel uses (fixed palettes), keeping the order. let mut used = [false; 256]; for (i, &ix) in indices.iter().enumerate() { diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index ae74160c9..03f2fcc1c 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -264,6 +264,7 @@ fn art_bounds_take_in_the_effects_of_groups_type_and_images() { xf: vectorcraft_geom::Affine::translate((20.0, 50.0)), link: None, placement: Default::default(), + pixels: None, }; let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); let cut_off: Vec<_> = [("group", group), ("type", text), ("image", image)] diff --git a/crates/render/src/encode/tga.rs b/crates/render/src/encode/tga.rs index 884276eec..abac01dba 100644 --- a/crates/render/src/encode/tga.rs +++ b/crates/render/src/encode/tga.rs @@ -34,11 +34,11 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &TgaOptions) -> Result().0; - let mut out = Vec::with_capacity(18 + w * h * usize::from(o.depth / 8) + 26); + let mut out = Vec::with_capacity((w * h).saturating_mul(usize::from(o.depth / 8)).saturating_add(18 + 26)); // No image id or colour map, uncompressed true colour, origin (0, 0). out.extend([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]); out.extend(w16.to_le_bytes()); diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index e7da4a878..05a145156 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -11,7 +11,7 @@ use std::hash::{Hash, Hasher}; use std::sync::Arc; use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; -use vectorcraft_color::{Color, Freeform, GradientPaint}; +use vectorcraft_color::{Freeform, GradientPaint}; use vectorcraft_geom::{Affine, Rect}; use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; @@ -24,22 +24,13 @@ thread_local! { static GRIDS: RefCell>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX)); } -pub(crate) fn hash_color(c: &Color, h: &mut impl Hasher) { - match *c { - Color::Rgb { r, g, b } => (0u8, r.to_bits(), g.to_bits(), b.to_bits()).hash(h), - Color::Cmyk { c, m, y, k } => (1u8, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()).hash(h), - Color::Gray { k } => (2u8, k.to_bits()).hash(h), - Color::Lab { l, a, b } => (3u8, l.to_bits(), a.to_bits(), b.to_bits()).hash(h), - } -} - /// Cache key of the grid of `f` on box `b` at `cols` × `rows` painted with `ink` (and the active /// colour settings, which turn colours into display RGB or inks). fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); for p in &f.points { (p.at.x.to_bits(), p.at.y.to_bits(), p.opacity.to_bits(), p.spread.to_bits()).hash(&mut h); - hash_color(&p.color, &mut h); + p.color.key().hash(&mut h); } f.lines.hash(&mut h); (b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 50c19a35c..cb314ff61 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -316,7 +316,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { }; match &n.kind { _ if effects::has_container_appearance(n) => { - let art = effects::evaluate_container(n); + let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); if art.clips() { art.visual_bounds_with(symbols) } else { union(art) } } @@ -350,7 +350,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - let reshaped = effects::reshape(n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); let b = match &reshaped { Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) @@ -430,7 +430,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)), // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. _ if effects::has_container_appearance(n) => { - let art = effects::evaluate_container(n); + let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); let b = if art.clips() { art.visual_bounds_with(symbols) @@ -453,7 +453,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - let reshaped = effects::reshape(n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); let b = match &reshaped { Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) @@ -1483,14 +1483,14 @@ impl Renderer { let art = match effects::crop_marks_art(a) .or_else(|| effects::revolve_art(a)) .or_else(|| effects::solid_art(a)) - .or_else(|| effects::reshape(a, symbol.as_ref())) + .or_else(|| effects::reshape(Some(doc), a, symbol.as_ref())) { Some(r) => r, - None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), + None if container => effects::evaluate_container(Some(doc), a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), - None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), + None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; - let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art }; + let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); @@ -2964,8 +2964,15 @@ mod tests { let mut d = Document::new(100.0, 100.0); // A PNG signature with nothing after it. d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = - ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "cut".into(), + width: 40, + height: 40, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); n.appearance.effects = vec![Effect { id: "blur.gaussian".into(), params: json!({"radius": 4.0}), visible: true }]; d.insert(Some(d.layers[0].id), 0, n).unwrap(); @@ -2989,8 +2996,15 @@ mod tests { let mut d = Document::new(100.0, 100.0); // A PNG signature with nothing after it. d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = - ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "cut".into(), + width: 40, + height: 40, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); n.appearance.effects = [ ("rasterize", json!({"background": "white"})), diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index c7f7b5acd..4383d515a 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -19,8 +19,9 @@ pub(crate) struct Composite<'a> { pub clip: Option<(&'a BezPath, FillRule)>, pub blend: Blend, pub opacity: f32, - /// Opacity mask (the context's size). - pub mask: Option, + /// Opacity mask: the coverage of each pixel (row-major) of the region `bounds` covers + /// ([`area`]). A masked group is drawn offscreen over that region only. + pub mask: Option>, /// The content starts from transparency instead of from the art below. pub isolated: bool, /// Blending inside reaches the group's backdrop ([`Node::blends_through`]). @@ -41,11 +42,11 @@ pub(crate) type Content<'d> = dyn FnMut(&mut Renderer, &mut RenderContext, &Fram /// A pixel region of a context: origin and size. #[derive(Clone, Copy)] -struct Region { - x: u16, - y: u16, - w: u16, - h: u16, +pub(crate) struct Region { + pub x: u16, + pub y: u16, + pub w: u16, + pub h: u16, } impl Renderer { @@ -73,13 +74,23 @@ impl Renderer { return; } if c.blends && (c.isolated || self.nested == 0) { - return self.group_offscreen(ctx, f, c, draw); + let Some(r) = region(ctx, f, c.bounds) else { return }; + // Past the budget for offscreen pixels, the group is drawn as a layer (isolated). + // A masked group is drawn offscreen whatever the budget: its mask scales its pixels. + if c.mask.is_some() || self.offscreen_fits(ctx, r, c.isolated) { + return self.group_offscreen(ctx, f, c, r, draw); + } + } + if c.mask.is_some() { + // Its backdrop out of reach (or not needed), a masked group starts from transparency. + let Some(r) = region(ctx, f, c.bounds) else { return }; + return self.group_offscreen(ctx, f, Composite { isolated: true, ..c }, r, draw); } ctx.set_transform(f.view); if let Some((_, rule)) = c.clip { ctx.set_fill_rule(fill_rule(rule)); } - ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), c.mask, None); + ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), None, None); self.nested += 1; // The layer starts from transparency: no backdrop for the content. let backdrop = self.backdrop.take(); @@ -89,47 +100,69 @@ impl Renderer { ctx.pop_layer(); } - fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, draw: &mut Content) { - let Composite { clip, blend, opacity, mask, isolated, bounds, .. } = c; - let Some(r) = region(ctx, f, bounds) else { return }; - let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; - let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); - let sub = Frame { mt: false, view, visible, ..*f }; + /// Whether a group drawn offscreen over region `r` keeps the offscreen pixels alive within + /// [`OFFSCREEN_FRAMES`] times the frame `ctx` (the frame's own context when none is alive). + fn offscreen_fits(&self, ctx: &RenderContext, r: Region, isolated: bool) -> bool { + let budget = + if self.offscreen_live == 0 { OFFSCREEN_FRAMES * u64::from(ctx.width()) * u64::from(ctx.height()) } else { self.offscreen_budget }; + self.offscreen_live.saturating_add(offscreen_cost(r, isolated)) <= budget + } + + fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, r: Region, draw: &mut Content) { + let Composite { clip, blend, opacity, mask, isolated, .. } = c; + if self.offscreen_live == 0 { + self.offscreen_budget = OFFSCREEN_FRAMES * u64::from(ctx.width()) * u64::from(ctx.height()); + } + let cost = offscreen_cost(r, isolated); + self.offscreen_live = self.offscreen_live.saturating_add(cost); + let sub = frame_over(f, r); let backdrop = (!isolated).then(|| self.flatten(ctx, r)); let content = self.offscreen(r, &sub, backdrop.clone(), draw); let at = (r.x as f64, r.y as f64); - let push = |ctx: &mut RenderContext, mode: BlendMode, mask: Option| { + let push = |ctx: &mut RenderContext, mode: BlendMode| { ctx.set_transform(f.view); if let Some((_, rule)) = clip { ctx.set_fill_rule(fill_rule(rule)); } - ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), mask, None); + ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), None, None); + }; + // The mask scales what the group draws, as a layer mask does. + let masked = |pm: Pixmap| match &mask { + Some(m) => apply_mask(pm, m), + None => pm, }; match backdrop { // The backdrop under the content, mixed with it by opacity, mask and clip: take that // share of the backdrop out, then add the same share of the content. Some(_) if blend == Blend::Normal => { - push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut), mask.clone()); - ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(peniko::Color::BLACK); - ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64)); + push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut)); + if mask.is_some() { + let mut black = Pixmap::new(r.w, r.h); + black.data_mut().iter_mut().for_each(|p| p.a = 255); + draw_pixmap(ctx, Arc::new(masked(black)), at); + } else { + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(peniko::Color::BLACK); + ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64)); + } ctx.pop_layer(); - push(ctx, BlendMode::new(Mix::Normal, Compose::Plus), mask); - draw_pixmap(ctx, Arc::new(content), at); + push(ctx, BlendMode::new(Mix::Normal, Compose::Plus)); + draw_pixmap(ctx, Arc::new(masked(content)), at); } // A blend mode applies to the group as an object of its own: take the backdrop's share // out of the content (it needs the content's own coverage), then blend the rest. Some(b) => { let alone = self.offscreen(r, &sub, None, draw); - push(ctx, blend_mode(blend), mask); - draw_pixmap(ctx, Arc::new(remove_backdrop(content, &alone, &b)), at); + push(ctx, blend_mode(blend)); + draw_pixmap(ctx, Arc::new(masked(remove_backdrop(content, &alone, &b))), at); } None => { - push(ctx, blend_mode(blend), mask); - draw_pixmap(ctx, Arc::new(content), at); + push(ctx, blend_mode(blend)); + draw_pixmap(ctx, Arc::new(masked(content)), at); } } ctx.pop_layer(); + self.offscreen_live = self.offscreen_live.saturating_sub(cost); } /// Render what `ctx` holds so far, and start it again from that picture (so later drawing sees @@ -288,6 +321,16 @@ thread_local! { pub(crate) static PIXELS_DRAWN: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// The offscreen pixels that groups drawn offscreen may keep alive at once, in frames: deeper +/// nested blending groups are drawn as layers instead (#3581). +const OFFSCREEN_FRAMES: u64 = 4; + +/// The pixels a group drawn offscreen over `r` keeps alive: its content, and when it isn't +/// isolated the backdrop copy and the content drawn alone. +fn offscreen_cost(r: Region, isolated: bool) -> u64 { + u64::from(r.w) * u64::from(r.h) * if isolated { 1 } else { 3 } +} + /// The pixel region of `ctx` that `bounds` (document space) covers, with a margin for /// antialiasing; `None` when it is off the context. fn region(ctx: &RenderContext, f: &Frame, bounds: Option) -> Option { @@ -300,6 +343,29 @@ fn region(ctx: &RenderContext, f: &Frame, bounds: Option) -> Option x0 && y1 > y0).then_some(Region { x: x0 as u16, y: y0 as u16, w: (x1 - x0) as u16, h: (y1 - y0) as u16 }) } +/// The pixel region of `ctx` that `bounds` covers ([`region`]), and the frame that draws into a +/// context of that region's size. +pub(crate) fn area<'a>(ctx: &RenderContext, f: &Frame<'a>, bounds: Option) -> Option<(Region, Frame<'a>)> { + let r = region(ctx, f, bounds)?; + Some((r, frame_over(f, r))) +} + +/// Frame `f` drawing into a context of region `r`'s size (on the calling thread). +fn frame_over<'a>(f: &Frame<'a>, r: Region) -> Frame<'a> { + let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); + Frame { mt: false, view, visible, ..*f } +} + +/// `pm` (premultiplied) with each pixel scaled by its coverage in `mask` (row-major, `pm`'s size). +fn apply_mask(mut pm: Pixmap, mask: &[u8]) -> Pixmap { + let scale = |v: u8, m: u8| ((v as u32 * m as u32 + 127) / 255) as u8; + for (p, &m) in pm.data_mut().iter_mut().zip(mask) { + (p.r, p.g, p.b, p.a) = (scale(p.r, m), scale(p.g, m), scale(p.b, m), scale(p.a, m)); + } + pm +} + /// The pixels of region `r` of `pm`. fn cropped(pm: &Pixmap, r: Region) -> Pixmap { let mut out = Pixmap::new(r.w, r.h); diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index ad4e8cb23..1dd894b4a 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -121,12 +121,12 @@ impl Renderer { /// The inks of `c` in the working CMYK space: CMYK as it is, grey as black ink, RGB and Lab /// separated with the colour settings' intent (cached: separating searches the profile). fn inks(c: &Color) -> [f32; 4] { - let key = match *c { + match *c { Color::Cmyk { c, m, y, k } => return [c, m, y, k], Color::Gray { k } => return [0.0, 0.0, 0.0, k], - Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits()], - Color::Lab { l, a, b } => [1, l.to_bits(), a.to_bits(), b.to_bits()], - }; + Color::Rgb { .. } | Color::Lab { .. } => {} + } + let key = c.key(); let cms = cms::active(); SEPARATED.with(|s| { let (owner, seen) = &mut *s.borrow_mut(); @@ -141,7 +141,7 @@ fn inks(c: &Color) -> [f32; 4] { } /// Separated colours, for the colour settings they were separated with. -type Separated = (Option>, HashMap<[u32; 4], [f32; 4]>); +type Separated = (Option>, HashMap<[u32; 5], [f32; 4]>); thread_local! { static SEPARATED: RefCell = RefCell::default(); @@ -410,7 +410,8 @@ mod tests { let mut d = Document::new_with_mode(20.0, 10.0, ColorMode::Cmyk); d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap()); let l = d.layers[0].id; - let im = ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default() }; + let im = + ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default(), pixels: None }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); // The same inks as a fill beside it. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fcb73fff5..e5f345ef0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -477,6 +477,10 @@ pub struct Renderer { /// [`Self::draw_outline_member`] sets it to each member of a clip group or shape of an Image /// Trace object that it draws. fx_owner: Option>, + /// The live object [`Self::draw_kept_node`] is drawing (kept symbol art, an opacity mask's art, + /// a member of a clip group in Outline view): [`Self::draw_content`] draws its evaluation from + /// the live cache. + live_root: Option>, /// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`. fx_geom: PtrMap, stamp: u64, @@ -528,6 +532,11 @@ pub struct Renderer { /// The picture [`Self::flatten`] started the current context again from, with 8-bit /// compositing: [`Self::finish`] renders the context over it. picture: Option>, + /// Pixels of the groups being drawn offscreen (contexts and backdrop copies), and the most + /// they may hold (set from the frame when the first opens, see [`Self::group`]); both reset + /// at the start of every frame. + offscreen_live: u64, + offscreen_budget: u64, /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). clip_paints: FrameCache, /// Gradients along or across strokes (see [`Self::fill_path_gradient`]), keyed by @@ -610,6 +619,8 @@ impl Frame<'_> { /// A render context for drawing this frame's art offscreen (on the calling thread), with its /// edge smoothing: hard edges must not depend on where the art is drawn. fn offscreen_context(&self, w: u16, h: u16) -> RenderContext { + #[cfg(test)] + OFFSCREEN_PIXELS.with(|n| n.set(n.get() + u64::from(w) * u64::from(h))); let mut ctx = single_threaded_context(w, h); ctx.set_aliasing_threshold(self.opts.anti_alias.threshold()); ctx @@ -630,6 +641,7 @@ impl Renderer { cur: None, made_again: false, fx_owner: None, + live_root: None, fx_geom: PtrMap::default(), stamp: 0, alpha: 1.0, @@ -651,6 +663,8 @@ impl Renderer { blends: PtrMap::default(), clip_paths: vec![], picture: None, + offscreen_live: 0, + offscreen_budget: 0, clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), stroke_slices: PtrMap::default(), adjusts: HashMap::new(), @@ -735,6 +749,8 @@ impl Renderer { fn begin_frame(&mut self) { self.stamp += 1; self.inline_depth = 0; + // Nothing is drawn offscreen yet (even if an earlier frame stopped part way). + (self.offscreen_live, self.offscreen_budget) = (0, 0); } /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, @@ -1075,21 +1091,18 @@ impl Renderer { && !f.opts.outline && !self.is_shape(a) { - // A masked object is a non-isolated group of its own. - let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height())); + // A masked object is a non-isolated group of its own, drawn over its own pixels: the + // mask is rendered over those only. + let bounds = self.bounds_of(f.symbols, a); + let Some((r, sub)) = group::area(ctx, f, bounds) else { return }; + let mask = Some(self.mask_values(&sub, m, r.w, r.h)); let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); - let bounds = if blends { self.bounds_of(f.symbols, a) } else { None }; self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); return; } self.draw_arc_body(ctx, f, a); } - /// Render an opacity mask's art offscreen and turn its luminance into a coverage mask. - fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask { - vello_cpu::Mask::from_parts(self.mask_values(f, m, w, h), w, h) - } - /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, /// as luminance ([`mask_value`]). fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec { @@ -1103,7 +1116,7 @@ impl Renderer { std::mem::take(&mut self.clip_paths), self.picture.take(), ); - self.draw_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art, true); + self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art); let pm = self.finish(&mut mctx, self.raster); (self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer; pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect() @@ -1357,9 +1370,7 @@ impl Renderer { NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { let (art, kept) = self.placed_art(&sym.art, *xf, false, Some(n)); - let outer = std::mem::replace(&mut self.made_again, !kept); - self.draw_node(ctx, f, &art, true); - self.made_again = outer; + self.draw_placed_art(ctx, f, &art, kept); } } NodeKind::Blend { .. } @@ -1367,10 +1378,35 @@ impl Renderer { | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => self.draw_live_node(ctx, f, n), + | NodeKind::Intertwine { .. } => match self.live_root.clone().filter(|a| std::ptr::eq(&**a, n)) { + Some(a) => self.draw_live_body(ctx, f, &a, true), + None => self.draw_live_node(ctx, f, n), + }, } } + /// Draw symbol art `art` as [`Self::placed_art`] placed it for an instance or an inline graphic + /// (`kept`: kept between frames, see [`Self::draw_kept_node`]). + fn draw_placed_art(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc, kept: bool) { + let outer = std::mem::replace(&mut self.made_again, !kept); + if kept { + self.draw_kept_node(ctx, f, art); + } else { + self.draw_node(ctx, f, art, true); + } + self.made_again = outer; + } + + /// [`Self::draw_node`] of `a` (drawn whatever its visibility and bounds), whose `Arc` stays the + /// same from frame to frame while the art does. When `a` is a live object, + /// [`Self::draw_content`] draws its evaluation from the live cache ([`Self::live_root`]). + fn draw_kept_node(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + let live = vectorcraft_doc::live::is_live(a).then(|| a.clone()); + let outer = std::mem::replace(&mut self.live_root, live); + self.draw_node(ctx, f, a, true); + self.live_root = outer; + } + /// An Image Trace object's `children` (the hidden source image, then the traced shapes) as /// `view` shows them on screen. fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], view: TraceView, knockout: bool) { @@ -1392,7 +1428,8 @@ impl Renderer { /// [`Self::draw_node`] for `c`, a member of a clip group or a shape of an Image Trace object in /// Outline view, culled by its cached bounds ([`Self::bounds_of`]) and drawn with `c` as - /// `fx_owner`, so its path and live-effect geometry come from the caches. + /// `fx_owner`, so its path, its live-effect geometry and, through [`Self::draw_kept_node`], a + /// live object's evaluation come from the caches. fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc) { if !c.visible || f.opts.hidden.contains(&c.id) || f.opts.hide_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { return; @@ -1409,7 +1446,7 @@ impl Renderer { None => {} } let outer = self.fx_owner.replace(c.clone()); - self.draw_node(ctx, f, c, true); + self.draw_kept_node(ctx, f, c); self.fx_owner = outer; } @@ -1738,24 +1775,23 @@ impl Renderer { let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue }; // Strokes scale with the art (it is sized to the type, as in the SVG ``). let (node, kept) = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None); - let outer = std::mem::replace(&mut self.made_again, !kept); - self.draw_node(ctx, f, &node, true); - self.made_again = outer; + self.draw_placed_art(ctx, f, &node, kept); } self.inline_depth -= 1; } /// Symbol art `art` as an instance or an inline graphic draws it ([`place`]), and whether it is - /// kept. Art that is a group or a layer without an appearance of its own is kept between frames - /// under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance `inst`: its members - /// keep their `Arc`s from frame to frame, and the caches keyed by a node's address (geometry, - /// stroke outlines, glyphs, live-effect geometry) hit for them. Other art is placed again in - /// every frame, and so is art that holds symbol instances, type with inline graphics, images, - /// placed documents, live blends, envelopes or repeats, or raster effects + /// kept. Art that is a group, a layer or a live object without an appearance of its own is kept + /// between frames under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance + /// `inst`: it and its members keep their `Arc`s from frame to frame, and the caches keyed by a + /// node's address (geometry, stroke outlines, glyphs, live-effect geometry, evaluated live + /// objects) hit for them. Other art is placed again in every frame, and so is art that holds + /// symbol instances, type with inline graphics, images, placed documents or raster effects /// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is /// placed again when the fonts change. fn placed_art(&mut self, art: &Arc, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc, bool) { - if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || fx::has_object_fx(art) { + let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art); + if !kept_kind || fx::has_object_fx(art) { return (place(art, xf, scale_strokes, inst), false); } let stain = stain_bits(inst.and_then(vectorcraft_brush::stain)); @@ -1973,11 +2009,11 @@ impl Renderer { let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64); let shown = if outline && !f.doc.setup.outline_images { None - } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, outline, unit)).flatten() { + } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() { Some(pm) } else { - self.image_pixmap(f.doc, &im.key, outline, !mips).map(|(key, pm)| { - let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.images.get(&im.key), !mips) }; + self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| { + let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) }; if mips { image_mip(&key, pm, unit) } else { pm } }) }; @@ -2039,15 +2075,22 @@ impl Renderer { self.adjusts.insert(derived, (key.to_string(), hash, map.clone())); } - /// The pixels of image `key` (a blob of `doc`, or a recoloured one named by - /// [`Self::adjusted_image`]), or a greyscale copy of them, with their key in the shared store. - /// `touch` marks them used there. - fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool, touch: bool) -> Option<(images::ImageKey, Arc)> { - let (key, colour) = match doc.images.get(key) { + /// The pixels of image `key` (`own`, the pixels an image holds itself, a blob of `doc`, or a + /// recoloured one named by [`Self::adjusted_image`]), or a greyscale copy of them, with their + /// key in the shared store. `touch` marks them used there. + fn image_pixmap( + &mut self, + doc: &Document, + key: &str, + own: Option<&vectorcraft_doc::ImageBlob>, + grey: bool, + touch: bool, + ) -> Option<(images::ImageKey, Arc)> { + let (key, colour) = match own.or_else(|| doc.images.get(key)) { Some(blob) => blob_pixmap(blob, touch)?, None => { let (src, hash, map) = self.adjusts.get(key)?.clone(); - let (src_key, src) = self.image_pixmap(doc, &src, false, touch)?; + let (src_key, src) = self.image_pixmap(doc, &src, None, false, touch)?; let key = images::ImageKey { adjust: Some(hash), ..src_key }; match lookup(&key, touch) { Some(pm) => (key, pm), @@ -2086,28 +2129,28 @@ impl Renderer { Some((key, pm)) } - /// The key [`Self::image_pixmap`] gives the pixels of image `key` and their size, without + /// The key [`Self::image_pixmap`] gives the pixels of image `key` (or `own`) and their size, without /// making or looking at them: `None` until the image has been decoded. - fn image_key(&self, doc: &Document, key: &str, grey: bool) -> Option<(images::ImageKey, (u16, u16))> { - let (key, size) = match doc.images.get(key) { + fn image_key(&self, doc: &Document, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool) -> Option<(images::ImageKey, (u16, u16))> { + let (key, size) = match own.or_else(|| doc.images.get(key)) { Some(blob) => { let (key, _, size) = blob_key(blob); (key, size?) } None => { let (src, hash, _) = self.adjusts.get(key)?; - let (src_key, size) = self.image_key(doc, src, false)?; + let (src_key, size) = self.image_key(doc, src, None, false)?; (images::ImageKey { adjust: Some(*hash), ..src_key }, size) } }; Some((if grey { images::ImageKey { grey: true, ..key } } else { key }, size)) } - /// The mip [`image_mip`] draws image `key` (in greyscale with `grey`, the outline view's) with - /// through `m` when the store holds it, marked used there; `None` when it draws the full pixels - /// or the mip is yet to be made. - fn kept_mip(&self, f: &Frame, key: &str, grey: bool, m: Affine) -> Option> { - let (key, (w, h)) = self.image_key(f.doc, key, grey)?; + /// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with + /// `grey` (the outline view's), when the store holds it, marked used there; `None` when it + /// draws the full pixels or the mip is yet to be made. + fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option> { + let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?; let key = if grey { key } else { ink::ink_key(&key, f.ink) }; let level = mip_level(m, w, h); if level == 0 { @@ -2279,19 +2322,14 @@ fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Ar } /// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type -/// with an inline graphic, an image, a placed document, a blend, an envelope, a repeat, or an -/// object or a fill or stroke with a visible raster effect? +/// with an inline graphic, an image, a placed document, or an object or a fill or stroke with a +/// visible raster effect? fn depends_on_document(n: &Node) -> bool { let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id)); let mut depends = false; n.walk(&mut |c| { depends |= match &c.kind { - NodeKind::SymbolInstance { .. } - | NodeKind::Image(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Repeat(_) => true, + NodeKind::SymbolInstance { .. } | NodeKind::Image(_) | NodeKind::PlacedDocument(_) => true, NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), _ => false, } || raster(&c.appearance.effects) @@ -2624,6 +2662,12 @@ fn now() -> u64 { 0 } +#[cfg(test)] +thread_local! { + /// The pixels of the offscreen contexts [`Frame::offscreen_context`] has made on this thread. + static OFFSCREEN_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 014c8efa4..992a7051d 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -39,11 +39,20 @@ type MeshEntry = (Arc, Quads, Arc); pub(crate) struct LiveCache { expanded: FrameCache, Expanded, Found)>, meshes: FrameCache<(usize, usize), MeshEntry>, + /// How many live objects [`Renderer::live_expanded`] evaluated and how many meshes + /// [`Renderer::mesh_quads`] tessellated. + #[cfg(test)] + pub(crate) evaluated: usize, } impl Default for LiveCache { fn default() -> Self { - Self { expanded: FrameCache::new(lru::LIVE_BUDGET, 256), meshes: FrameCache::new(lru::MESH_BUDGET, 256) } + Self { + expanded: FrameCache::new(lru::LIVE_BUDGET, 256), + meshes: FrameCache::new(lru::MESH_BUDGET, 256), + #[cfg(test)] + evaluated: 0, + } } } @@ -400,6 +409,10 @@ impl Renderer { { return e.1.clone(); } + #[cfg(test)] + { + self.live.evaluated += 1; + } let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) { Some(children) => Arc::new(children), None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()), @@ -423,6 +436,10 @@ impl Renderer { { return e.1.clone(); } + #[cfg(test)] + { + self.live.evaluated += 1; + } let q = Arc::new(m.quads(level)); if cache { let bytes = q.len() * size_of::(); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index e6ea284ba..c2150c76e 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -621,7 +621,8 @@ mod tests { let mut d = doc_with_pattern(TileType::Grid); // A PNG signature with nothing after it. d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = + ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; let mut r = Renderer::new(); let start = tiles_rasterized(); diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index b207e2c49..c26336de1 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -120,12 +120,25 @@ impl Bitmaps { } } +/// Runs a closure in the file-access scope of whoever scheduled a job (see [`set_scope_capture`]). +pub type Scoped = Box; + +static SCOPE_CAPTURE: std::sync::OnceLock Scoped> = std::sync::OnceLock::new(); + +/// Register how the scope the caller reads files in is carried to the background workers, which +/// read placed files too (the first registration stays). The engine registers its automation roots. +pub fn set_scope_capture(f: fn() -> Scoped) { + let _ = SCOPE_CAPTURE.set(f); +} + /// What a bitmap is made from. struct Job { key: BitmapKey, object: PlacedDocument, blob: ImageBlob, step: f64, + /// The scheduler's file-access scope, which the worker runs the job in. + scope: Option, } /// Device pixels per point of `object`'s box its preview `bytes` have. @@ -192,12 +205,19 @@ fn pixels(v: f64) -> u16 { } /// Make `job`'s bitmap and keep it. -fn run(r: &mut Renderer, job: Job) { - if let Some(pm) = make(r, &job.object, &job.blob, job.step) { - lock(&BITMAPS).insert(job.key.clone(), pm, job.step); +fn run(r: &mut Renderer, mut job: Job) { + let scope = job.scope.take(); + let mut body = || { + if let Some(pm) = make(r, &job.object, &job.blob, job.step) { + lock(&BITMAPS).insert(job.key.clone(), pm, job.step); + } + lock(&PENDING).remove(&job.key); + GENERATION.fetch_add(1, Ordering::Relaxed); + }; + match scope { + Some(scoped) => scoped(&mut body), + None => body(), } - lock(&PENDING).remove(&job.key); - GENERATION.fetch_add(1, Ordering::Relaxed); } #[cfg(not(target_arch = "wasm32"))] @@ -317,7 +337,7 @@ impl Renderer { let shown = match found { Some(pm) => Some(pm), None => { - schedule(Job { key, object: p.clone(), blob, step }); + schedule(Job { key, object: p.clone(), blob, step, scope: SCOPE_CAPTURE.get().map(|capture| capture()) }); // Made here and now (no threads) or meanwhile another step's. let mut b = lock(&BITMAPS); b.get(&(p.key.clone(), step.to_bits())).or(nearest.map(|n| n.0)) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 2f907c5cd..bc5ede38b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -664,6 +664,34 @@ fn opacity_mask_without_clip_and_inverted() { assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible"); } +/// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 +/// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and +/// still draw where their masks show them. +#[test] +fn small_masks_render_over_their_objects_only() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + for i in 0..300 { + let (x, y) = (10.0 + (i % 30) as f64 * 30.0, 10.0 + (i / 30) as f64 * 30.0); + let square = Rect::new(x, y, x + 4.0, y + 4.0); + let mut n = rect_node(&mut d, square, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = rect_node(&mut d, square, Paint::solid(Color::WHITE), Paint::None, 0.0); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); + d.insert(Some(l), usize::MAX, n).unwrap(); + } + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + OFFSCREEN_PIXELS.with(|n| n.set(0)); + let img = r.render(&d, 1000, 1000, Affine::IDENTITY, &opts); + let px = OFFSCREEN_PIXELS.with(|n| n.get()); + // Each square's region is at most 4 + 2 × 2 margin + 1 rounding pixels a side, mask and content. + assert!(px <= 300 * 2 * 9 * 9, "{px} offscreen pixels for 300 masked 4 × 4 squares"); + assert_eq!(img.pixel(12, 12), [255, 0, 0, 255]); + assert_eq!(img.pixel(12 + 29 * 30, 12 + 9 * 30), [255, 0, 0, 255]); + assert_eq!(img.pixel(20, 20), [255, 255, 255, 255]); +} + #[test] fn disabled_or_outline_mask_draws_unmasked() { let mut d = masked_doc(true, false); @@ -805,7 +833,7 @@ fn images_take_their_nearest_pixel_without_smoothing() { let mut d = Document::new(40.0, 20.0); d.images.insert("bw".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); - let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; + let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None }; d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); // How many pixels across the image are neither black nor white. let greys = |smooth_images| { @@ -877,7 +905,8 @@ fn a_gray_image_draws_its_gray_levels() { let mut d = Document::new(40.0, 20.0); d.images.insert("gray".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); - let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; + let im = + ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None }; d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }); assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255])); diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests_adjust.rs index 5ff5a35d4..1457c4df5 100644 --- a/crates/render/src/tests_adjust.rs +++ b/crates/render/src/tests_adjust.rs @@ -93,6 +93,7 @@ fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() { xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default(), + pixels: None, })), ); n.appearance.effects.push(hue()); diff --git a/crates/render/src/tests_bitmapglyphs.rs b/crates/render/src/tests_bitmapglyphs.rs index 821083e0e..eddff0c36 100644 --- a/crates/render/src/tests_bitmapglyphs.rs +++ b/crates/render/src/tests_bitmapglyphs.rs @@ -45,3 +45,40 @@ fn outline_mode_draws_a_bitmap_glyph_as_its_box() { // The box's left edge at x 32.5 (the bearing of 2 px of 6.25 pt), from y 45 to 145. assert!((31..=34).any(|x| img.pixel(x, 100)[0] < 128), "the box's edge shows"); } + +/// 100 pt [`COLOUR_CHAR`] in the `CBDT` test font with `effects`, on a white 160×160 page. +fn render_with(effects: Vec) -> Rendered { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 100.0, ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st)))); + let mut appearance = (*n.appearance).clone(); + appearance.effects = effects; + n.appearance = appearance.into(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect { + vectorcraft_doc::Effect { id: id.into(), params, visible: true } +} + +/// Type with a geometry effect is drawn as its outlines: its emoji go with them, as images that +/// hold their own pixels (here moved 40 pt by a Transform effect). +#[test] +fn bitmap_glyphs_go_with_type_that_a_geometry_effect_reshapes() { + let img = render_with(vec![effect("distort.transform", serde_json::json!({"moveH": 40}))]); + assert_eq!(img.pixel(110, 80), COLOUR_LARGE, "moved with the type"); + assert_eq!(img.pixel(50, 80), [255, 255, 255, 255], "not where it was"); +} + +/// A colour adjustment leaves the emoji of reshaped type in their colours, as it does those of +/// type, and as exports and Expand Appearance draw them. +#[test] +fn a_colour_adjustment_leaves_the_emoji_of_reshaped_type_as_they_are() { + let moved = effect("distort.transform", serde_json::json!({"moveH": 40})); + let img = render_with(vec![moved, effect("adjust.hueSaturation", serde_json::json!({"hue": 120}))]); + assert_eq!(img.pixel(110, 80), COLOUR_LARGE); +} diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 17afce50b..6a603842a 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -222,6 +222,7 @@ fn placed_images_are_separated_not_blackened() { xf: Affine::IDENTITY, link: None, placement: Default::default(), + pixels: None, })), ); add(&mut d, n); @@ -256,7 +257,7 @@ fn rich_black_shows_black_ink_alone_as_rgb_black() { let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); d.images.insert("red".into(), ImageBlob::new("image/png", png)); - let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); add(&mut d, n); assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is"); diff --git a/crates/render/src/tests_colrglyphs.rs b/crates/render/src/tests_colrglyphs.rs index 349b56842..38f07f888 100644 --- a/crates/render/src/tests_colrglyphs.rs +++ b/crates/render/src/tests_colrglyphs.rs @@ -87,3 +87,37 @@ fn a_sweep_glyph_draws_its_image() { assert!(ink(&img, |p| p[0] > 180 && p[1] < 60 && p[2] < 120).is_some(), "red where the sweep starts"); assert!(ink(&img, magenta).is_none()); } + +/// Type with a geometry effect is drawn reshaped as its outlines: its colour glyphs go with them, +/// as their art (here moved 40 pt by a Transform effect). +#[test] +fn colour_glyphs_go_with_type_that_a_geometry_effect_reshapes() { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { font_family: colr_family(false).into(), size: 100.0, ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st)))); + let moved = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 40}), visible: true }; + let mut appearance = (*n.appearance).clone(); + appearance.effects.push(moved); + n.appearance = appearance.into(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let img = Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let red = ink(&img, |p| p == [255, 0, 0, 255]).expect("the `O` layer, in red"); + assert!(red.0 >= 58, "moved right with the type: {red:?}"); +} + +/// Type's outlines stack as the canvas paints type: a run's colour glyphs under its fill, so a +/// plain character set over a colour one stays on top. +#[test] +fn outlined_colour_glyphs_stay_under_their_runs_fill() { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { font_family: colr_family(false).into(), size: 100.0, ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &format!("{COLOUR_CHAR}F"), st)))); + let outlined = vectorcraft_effects::outline_text(&n).unwrap(); + let kinds: Vec = outlined.children().unwrap().iter().map(|c| matches!(c.kind, NodeKind::Group { .. })).collect(); + assert_eq!(kinds, [true, false], "the colour glyph's group, then the run's path"); +} diff --git a/crates/render/src/tests_container.rs b/crates/render/src/tests_container.rs index 558b78f65..0bb071147 100644 --- a/crates/render/src/tests_container.rs +++ b/crates/render/src/tests_container.rs @@ -158,7 +158,15 @@ fn group_geometry_effects_move_and_copy_images_too() { image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); d.images.insert("red".into(), ImageBlob::new("image/png", png)); let b = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), solid(0.0, 0.0, 1.0)); - let im = ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((10.0, 30.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "red".into(), + width: 30, + height: 30, + xf: Affine::translate((10.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))); let mut g = Node::group(d.alloc_id(), vec![b, image]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 50, "copies": 1}))); diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 7b2296920..d4d05b48a 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -416,3 +416,27 @@ fn isolation_mode_dims_the_art_around_the_isolated_group() { // An object that isn't in the document dims nothing. assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels); } + +/// Deeply nested isolated blending groups (#3581): each level is drawn offscreen only while the +/// offscreen pixels alive stay within a few frames' worth; deeper levels are drawn as layers. +#[test] +fn nested_isolated_blending_groups_stay_within_the_offscreen_budget() { + let d = doc(|d| { + let mut n = multiply(d); + for _ in 0..30 { + n = group(d, vec![n], 1.0, Blend::Multiply, true); + } + n + }); + let drawn = || crate::group::PIXELS_DRAWN.with(std::cell::Cell::get); + let before = drawn(); + let frame = render(&d); + let images = drawn() - before; + // Each level's offscreen region is the content's bounds (20..80) plus `region`'s 2 px + // antialiasing margin on each side: 64 × 64. A budget of 4 frames fits whole levels only. + let level = 64 * 64; + let budget = 4 * 100 * 100 / level * level; + assert!(images <= budget, "{images} pixels drawn from offscreen groups, budget {budget}"); + assert_near(frame.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the groups"); + assert_near(frame.pixel(10, 10), B, "outside the groups"); +} diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests_layeropts.rs index 6555e827f..7c76f4c31 100644 --- a/crates/render/src/tests_layeropts.rs +++ b/crates/render/src/tests_layeropts.rs @@ -27,6 +27,7 @@ fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document { xf: Affine::translate((50.0, 10.0)), link: None, placement: Default::default(), + pixels: None, })), ); im.id = d.alloc_id(); diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index edbc5e028..8b1751c85 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -26,7 +26,7 @@ fn doc_with_image(key: &str, bytes: Vec, w: u32, h: u32) -> Document { let mut d = Document::new(w as f64, h as f64); d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let (id, layer) = (d.alloc_id(), d.layers[0].id); - let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } @@ -39,16 +39,16 @@ fn renderers_share_one_decode_of_an_image() { // Another document holding the same image under another key (its own copy of the bytes). let b = doc_with_image("photo", bytes, 16, 16); let (mut r1, mut r2) = (Renderer::new(), Renderer::new()); - let (k1, p1) = r1.image_pixmap(&a, "img", false, true).unwrap(); - let (k2, p2) = r2.image_pixmap(&b, "photo", false, true).unwrap(); + let (k1, p1) = r1.image_pixmap(&a, "img", None, false, true).unwrap(); + let (k2, p2) = r2.image_pixmap(&b, "photo", None, false, true).unwrap(); assert_eq!(k1, k2); assert!(Arc::ptr_eq(&p1, &p2), "decoded once for both renderers"); // Drawing goes through the store too: the pixels stay the ones decoded. let _ = r2.render(&b, 16, 16, Affine::IDENTITY, &RenderOptions::default()); - assert!(Arc::ptr_eq(&r1.image_pixmap(&b, "photo", false, true).unwrap().1, &p1)); + assert!(Arc::ptr_eq(&r1.image_pixmap(&b, "photo", None, false, true).unwrap().1, &p1)); // A document using the same key for other pixels gets its own. let c = doc_with_image("img", png(16, 16, 102), 16, 16); - let (k3, p3) = r1.image_pixmap(&c, "img", false, true).unwrap(); + let (k3, p3) = r1.image_pixmap(&c, "img", None, false, true).unwrap(); assert!(k3 != k1 && !Arc::ptr_eq(&p3, &p1)); assert!( r1.render(&c, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels @@ -102,7 +102,7 @@ fn image_mips_are_opt_in_and_change_nothing_off() { // On, the quarter-size image samples the quarter-size mip: close to the full pixels' average // but not the same pixels. assert!(mipped.pixels != cold.pixels); - let (key, _) = warm.image_pixmap(&d, "img", false, true).unwrap(); + let (key, _) = warm.image_pixmap(&d, "img", None, false, true).unwrap(); let mip = images::store().peek(&images::ImageKey { mip: 2, ..key }).expect("quarter mip kept"); assert_eq!((mip.width(), mip.height()), (16, 16)); // Above half size, mips on draw the full pixels. @@ -122,7 +122,7 @@ fn kept_mips_draw_without_decoding_the_full_pixels_again() { let quarter = Affine::scale(0.25); let mut r = Renderer::new(); let _ = r.render(&d, 32, 16, quarter, &on); - let full = r.image_pixmap(&d, "img", false, false).unwrap().0; + let full = r.image_pixmap(&d, "img", None, false, false).unwrap().0; images::store().remove(&full); // The image dragged 4 pt (a device pixel) right. let mut moved = d.clone(); @@ -475,7 +475,15 @@ fn art_placed_again_in_every_frame_keeps_one_outline_per_stroke() { let mut d = Document::new(200.0, 100.0); d.images.insert("dot".into(), ImageBlob::new("image/png", png(4, 4, 108))); let mut art = symbol_art(&mut d); - let im = ImageObject { key: "dot".into(), width: 4, height: 4, xf: Affine::translate((-2.0, -2.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "dot".into(), + width: 4, + height: 4, + xf: Affine::translate((-2.0, -2.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); if let Some(c) = art.children_mut() { c.push(Arc::new(image)); @@ -807,9 +815,8 @@ fn art_with_raster_effects_draws_pattern_and_resolution_edits() { }); } -/// Symbol art with a live object (an envelope that distorts a pattern fill) is placed again in -/// every frame: when the pattern's tile type changes, the next frame draws what a new renderer -/// draws. +/// Symbol art with a live object (an envelope that distorts a pattern fill) is kept between +/// frames: when the pattern's tile type changes, the next frame draws what a new renderer draws. #[test] fn art_with_live_objects_draws_pattern_layout_edits() { let _fonts = fonts_steady(); @@ -845,6 +852,128 @@ fn art_with_live_objects_draws_pattern_layout_edits() { }); } +/// Instances of four symbols whose art shows a live object (a group holding an envelope, an +/// envelope, a blend and a grid repeat), and an inline graphic of the envelope's symbol: the second +/// frame draws the evaluations the first made, and draws what a new renderer draws. +#[test] +fn live_objects_in_symbol_art_keep_their_evaluations_between_frames() { + let _fonts = fonts_steady(); + use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; + use vectorcraft_geom::shapes::rectangle; + let mut d = Document::new(200.0, 100.0); + let band = |d: &mut Document| filled(d, rectangle(Rect::new(-15.0, -8.0, 15.0, 8.0)), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), vec![]); + let b = band(&mut d); + let bent = arc_envelope(&mut d, vec![b], false); + let bent = Node::group(d.alloc_id(), vec![Arc::new(bent)]); + let b = band(&mut d); + let envelope = arc_envelope(&mut d, vec![b], false); + let square = |d: &mut Document, x: f64, c: Color| Arc::new(filled(d, rectangle(Rect::new(x, -4.0, x + 8.0, 4.0)), Paint::solid(c), vec![])); + let ends = vec![square(&mut d, -16.0, Color::rgb(1.0, 0.0, 0.0)), square(&mut d, 8.0, Color::rgb(0.0, 0.0, 1.0))]; + let spec = vectorcraft_doc::live::BlendSpec { spacing: vectorcraft_doc::live::BlendSpacing::Steps(4), ..Default::default() }; + let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) }); + let source = symbol_art(&mut d); + let grid = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::pattern::RepeatSpec::grid(vec![Arc::new(source)], 4.0, 4.0))); + let l = d.layers[0].id; + for (name, art, at) in + [("Bent", bent, (40.0, 25.0)), ("Envelope", envelope, (100.0, 25.0)), ("Blend", blend, (160.0, 25.0)), ("Grid", grid, (20.0, 60.0))] + { + d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(art) }); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate(at) })).unwrap(); + } + let style = CharStyle { size: 24.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(120.0, 85.0), "A", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("Envelope"), style)); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + let evaluated = r.live.evaluated; + assert!(evaluated >= 5, "the first frame evaluates the four live objects and the inline graphic's envelope: {evaluated}"); + let second = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.live.evaluated, evaluated, "the second frame evaluates none of them again"); + assert!(second == first, "the second frame draws the same"); + assert!(second == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels, "a new renderer draws the same"); +} + +/// A rectangle whose opacity mask is an envelope, drawn in Preview, and an envelope in a clip +/// group, drawn in Outline view: the second frame of each draws the evaluation the first made, and +/// draws what a new renderer draws. +#[test] +fn live_mask_art_and_live_clip_group_members_keep_their_evaluations_between_frames() { + let _fonts = fonts_steady(); + use vectorcraft_geom::shapes::rectangle; + let mut d = Document::new(200.0, 100.0); + let white = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::solid(Color::WHITE), vec![]); + let mask = arc_envelope(&mut d, vec![white], false); + let mut masked = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Paint::solid(Color::BLACK), vec![]); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true))); + let clip = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 190.0, 90.0)), Paint::None, vec![]); + let band = filled(&mut d, rectangle(Rect::new(100.0, 30.0, 200.0, 70.0)), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), vec![]); + let bent = arc_envelope(&mut d, vec![band], false); + let clipped = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(bent)], clip: true }); + let d = with_art(d, vec![masked, clipped]); + let preview = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let outline = RenderOptions { outline: true, ..preview.clone() }; + for (view, opts) in [("Preview", &preview), ("Outline", &outline)] { + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, opts).pixels; + let evaluated = r.live.evaluated; + assert!(evaluated >= 1, "{view}: the first frame evaluates the envelope: {evaluated}"); + let second = r.render(&d, 200, 100, Affine::IDENTITY, opts).pixels; + assert_eq!(r.live.evaluated, evaluated, "{view}: the second frame evaluates nothing again"); + assert!(second == first, "{view}: the second frame draws the same"); + assert!(second == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, opts).pixels, "{view}: a new renderer draws the same"); + } +} + +/// Symbol art that is an envelope, or a group holding one, bends a band filled with pattern `Dots` +/// (an instance of `S` at half size in each 20 × 20 tile). After two frames of its instance, the +/// next frame draws an edit of `S`'s art (a red bar) and a change of `Dots` to bricks as a new +/// renderer draws them. +#[test] +fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { + let _fonts = fonts_steady(); + use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint}; + use vectorcraft_geom::shapes::rectangle; + for grouped in [false, true] { + for bricks in [false, true] { + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let xf = Affine::translate((10.0, 10.0)) * Affine::scale(0.5); + let dot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + let mut dots = PatternDef::new("Dots", vec![Arc::new(dot)]); + dots.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(dots); + let band = filled(&mut d, rectangle(Rect::new(-80.0, -30.0, 80.0, 30.0)), pattern_paint("Dots"), vec![]); + let envelope = arc_envelope(&mut d, vec![band], true); + let art = if grouped { Node::group(d.alloc_id(), vec![Arc::new(envelope)]) } else { envelope }; + d.symbols.push(vectorcraft_doc::Symbol { name: "Bent".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bent".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 200, 100, Affine::IDENTITY, &opts); + let before = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + if bricks { + if let Some(def) = d.pattern_mut("Dots") { + def.tile_type = TileType::BrickByRow { offset: 0.5 }; + } + } else { + let bar = rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0)); + let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red)); + } + let after = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(after != before, "grouped {grouped}, bricks {bricks}: the edit changes the frame"); + let fresh = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(after == fresh, "grouped {grouped}, bricks {bricks}: a new renderer draws the same"); + } + } +} + /// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort /// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node { @@ -1355,7 +1484,7 @@ fn blurred_image_doc(key: &str, seed: u8) -> Document { let mut d = Document::new(200.0, 100.0); d.images.insert(key.into(), ImageBlob::new("image/png", png(16, 16, seed))); let xf = Affine::translate((20.0, 10.0)) * Affine::scale(5.0); - let im = ImageObject { key: key.into(), width: 16, height: 16, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 16, height: 16, xf, link: None, placement: Default::default(), pixels: None }; let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0}))); with_art(d, vec![n]) diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 95ecb085e..799de969d 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -285,6 +285,7 @@ fn image_doc(effects: Vec) -> Document { xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default(), + pixels: None, })), ); n.appearance.effects = effects; @@ -593,7 +594,7 @@ fn geometry_effects_reshape_type_through_its_outlines() { assert!(changed > 100, "{changed} pixels changed"); // The reshaped art is plain paths painted with the text's fill. let n = &d.layers[0].children().unwrap()[0]; - let art = vectorcraft_effects::reshape(n, None).unwrap(); + let art = vectorcraft_effects::reshape(None, n, None).unwrap(); let (mut text, mut black) = (0, 0); art.walk(&mut |m| { text += usize::from(matches!(m.kind, NodeKind::Text(_))); @@ -616,7 +617,7 @@ fn outline_object_draws_type_as_its_outlines_and_leaves_its_look() { // The type is drawn through its outlines: plain paths painted with the text's fill. let d = text_doc(vec![fx("path.outlineObject", json!({}))]); let n = &d.layers[0].children().unwrap()[0]; - let art = vectorcraft_effects::reshape(n, None).unwrap(); + let art = vectorcraft_effects::reshape(None, n, None).unwrap(); let (mut text, mut black) = (0, 0); art.walk(&mut |m| { text += usize::from(matches!(m.kind, NodeKind::Text(_))); @@ -649,7 +650,7 @@ fn outline_object_leaves_paths_and_images_as_they_are() { assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}"); // The image is drawn through its frame, as under any geometry effect. let d = image_doc(vec![fx("path.outlineObject", json!({}))]); - assert!(vectorcraft_effects::reshape(&d.layers[0].children().unwrap()[0], None).is_some()); + assert!(vectorcraft_effects::reshape(None, &d.layers[0].children().unwrap()[0], None).is_some()); } #[test] diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs index e8129f467..a03130c5f 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests_setup.rs @@ -10,7 +10,7 @@ fn doc_with_red_image() -> Document { image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); d.images.insert("red".into(), ImageBlob::new("image/png", png)); let (id, layer) = (d.alloc_id(), d.layers[0].id); - let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/render/tests/dash_align.rs b/crates/render/tests/it/dash_align.rs similarity index 100% rename from crates/render/tests/dash_align.rs rename to crates/render/tests/it/dash_align.rs diff --git a/crates/render/tests/golden.rs b/crates/render/tests/it/golden.rs similarity index 99% rename from crates/render/tests/golden.rs rename to crates/render/tests/it/golden.rs index f889b1721..4820cf8b6 100644 --- a/crates/render/tests/golden.rs +++ b/crates/render/tests/it/golden.rs @@ -1,7 +1,5 @@ //! Pixel-sample "golden" tests on small fixtures: gradients, blend modes, clip groups, stroke //! alignment/dashes/arrowheads, effects, text, and opacity folding vs layer compositing. -// 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 serde_json::json; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; diff --git a/crates/render/tests/gradient_alpha.rs b/crates/render/tests/it/gradient_alpha.rs similarity index 100% rename from crates/render/tests/gradient_alpha.rs rename to crates/render/tests/it/gradient_alpha.rs diff --git a/crates/render/tests/gradient_text.rs b/crates/render/tests/it/gradient_text.rs similarity index 94% rename from crates/render/tests/gradient_text.rs rename to crates/render/tests/it/gradient_text.rs index 6307a406a..2857058ce 100644 --- a/crates/render/tests/gradient_text.rs +++ b/crates/render/tests/it/gradient_text.rs @@ -1,6 +1,4 @@ //! Gradients on type: a vector across the text, and run strokes fitted like object strokes. -// 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 serde_json::json; use vectorcraft_color::Paint; diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs new file mode 100644 index 000000000..67afd6993 --- /dev/null +++ b/crates/render/tests/it/main.rs @@ -0,0 +1,10 @@ +//! 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 dash_align; +mod golden; +mod gradient_alpha; +mod gradient_text; +mod radial_focal; +mod stroke_geometry; diff --git a/crates/render/tests/radial_focal.rs b/crates/render/tests/it/radial_focal.rs similarity index 100% rename from crates/render/tests/radial_focal.rs rename to crates/render/tests/it/radial_focal.rs diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs similarity index 98% rename from crates/render/tests/stroke_geometry.rs rename to crates/render/tests/it/stroke_geometry.rs index b53ed21c3..a8813da23 100644 --- a/crates/render/tests/stroke_geometry.rs +++ b/crates/render/tests/it/stroke_geometry.rs @@ -1,7 +1,5 @@ //! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas: //! dotted lines (zero-length dashes) and arrowheads (hollow kinds, alignment, compositing). -// 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 vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Document, LineCap, StrokeLayer}; diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 52f3ab563..178528887 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -152,9 +152,12 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .features .iter() .map(|t| match t.strip_prefix('-') { - Some(off) => format!("\"{off}\" 0"), - None => format!("\"{t}\" 1"), + Some(off) => (off, 0), + None => (t.as_str(), 1), }) + // A feature tag is four letters or digits: anything else could end the CSS string. + .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) + .map(|(tag, on)| format!("\"{tag}\" {on}")) .collect(); // Proportional Metrics: `palt` (SVG writes vertical type as outlines). if st.proportional_metrics { diff --git a/crates/svg/src/entities.rs b/crates/svg/src/entities.rs new file mode 100644 index 000000000..a055e55fd --- /dev/null +++ b/crates/svg/src/entities.rs @@ -0,0 +1,99 @@ +//! A cap on what the entities of an SVG's document type declaration expand to. The XML reader +//! replaces every `&name;` with the entity's text as it reads, with no total limit: a short file +//! that repeats a large entity in an attribute allocates far more than it holds. + +use std::collections::HashMap; + +/// The most text the references to entities may expand to in all (bytes). +const MAX_EXPANDED: usize = 16 << 20; + +/// How deep entities that refer to entities are followed (the reader's own limit is lower). +const MAX_NESTING: u32 = 16; + +/// The internal general entities declared in `svg`: name → value. +fn declared(svg: &str) -> HashMap<&str, &str> { + let mut map = HashMap::new(); + for (at, _) in svg.match_indices(" impl Iterator { + text.match_indices('&').filter_map(|(at, _)| { + let rest = text.get(at + 1..)?; + // Look no further than the longest name: a file of `&`s without `;` would be read quadratically. + let end = rest.bytes().take(257).position(|b| b == b';')?; + let name = rest.get(..end)?; + (!name.is_empty() && name.len() <= 256 && !name.starts_with('#') && !name.contains(char::is_whitespace)).then_some(name) + }) +} + +/// What entity `name` expands to (bytes), references in its value followed. +fn expanded<'a>(name: &'a str, entities: &HashMap<&'a str, &'a str>, memo: &mut HashMap<&'a str, usize>, depth: u32) -> usize { + if let Some(n) = memo.get(name) { + return *n; + } + let Some(value) = entities.get(name) else { return 0 }; + if depth >= MAX_NESTING { + return usize::MAX; + } + let mut total = value.len(); + for inner in references(value) { + total = total.saturating_add(expanded(inner, entities, memo, depth + 1)); + } + memo.insert(name, total); + total +} + +/// Whether the references to the entities `svg` declares expand to more than [`MAX_EXPANDED`] +/// bytes in all. False for a file that declares none. +pub(crate) fn expansion_too_large(svg: &str) -> bool { + let entities = declared(svg); + if entities.is_empty() { + return false; + } + let mut memo = HashMap::new(); + let mut total = 0usize; + for name in references(svg) { + total = total.saturating_add(expanded(name, &entities, &mut memo, 0)); + if total > MAX_EXPANDED { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_repeated_large_entity_is_too_large_and_a_namespace_entity_is_not() { + let big = "x".repeat(1 << 20); + let refs = "&a;".repeat(20); + let bomb = format!("]>"); + assert!(expansion_too_large(&bomb)); + let nested = format!("]>", "&a;".repeat(8), "&b;".repeat(8)); + assert!(expansion_too_large(&nested)); + let ns = "]>"; + assert!(!expansion_too_large(ns)); + assert!(!expansion_too_large("")); + // Many `&`s with no `;` after them are read in linear time. + let ampersands = format!("]>", "&".repeat(1 << 20)); + let start = std::time::Instant::now(); + assert!(!expansion_too_large(&ersands)); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 07961c265..179cde321 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -54,6 +54,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: classes: Vec::new(), class_ids: HashMap::new(), used_ids: HashSet::new(), + next_suffix: HashMap::new(), + linked_names: HashSet::new(), names: HashMap::new(), depth: 1, patterns: HashMap::new(), @@ -304,6 +306,10 @@ struct Writer<'a> { /// 1-based class/entity number by declaration; avoids a linear scan for each style. class_ids: HashMap, used_ids: HashSet, + /// The next suffix to try for a base name, so a repeated name doesn't probe the ones taken again. + next_suffix: HashMap, + /// The names in `linked`. + linked_names: HashSet, names: HashMap, depth: usize, /// `` def ids by pattern name + placement. @@ -540,7 +546,7 @@ impl Writer<'_> { /// missing), else its bytes. `None` when it has neither. fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); - match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) { + match (im.link.as_ref().filter(|_| link), doc.image_blob(im)) { (Some(l), _) => Some(l.path.clone()), (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))), _ => im.link.as_ref().map(|l| l.path.clone()), @@ -550,17 +556,16 @@ impl Writer<'_> { /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs /// once; each piece then draws them with ``. fn share_images(&mut self, art: &Node) { - let mut seen: HashMap<(String, u32, u32), usize> = HashMap::new(); + // Each image's count and its first piece, which shows what they all show. + let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); art.walk(&mut |c| { if let NodeKind::Image(im) = &c.kind { - *seen.entry((im.key.clone(), im.width, im.height)).or_default() += 1; + seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; } }); - let mut repeated: Vec<_> = seen.into_iter().filter(|(k, n)| *n > 1 && !self.shared_images.contains_key(k)).map(|(k, _)| k).collect(); - repeated.sort(); - for (key, width, height) in repeated { - let link = self.linked_of(art, &key); - let im = vectorcraft_doc::ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link, placement: Default::default() }; + let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); + repeated.sort_by(|a, b| a.0.cmp(&b.0)); + for ((key, width, height), (_, im)) in repeated { let Some(href) = self.image_href(&im) else { continue }; let id = self.fresh_id("image"); self.def( @@ -574,20 +579,6 @@ impl Writer<'_> { } } - /// The link of the first image with blob `key` in `art`. - fn linked_of(&self, art: &Node, key: &str) -> Option { - let mut link = None; - art.walk(&mut |c| { - if let NodeKind::Image(im) = &c.kind - && im.key == key - && link.is_none() - { - link = im.link.clone(); - } - }); - link - } - fn fresh_id(&mut self, prefix: &str) -> String { let mut i = 1; loop { @@ -601,14 +592,17 @@ impl Writer<'_> { /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… fn unique_id(&mut self, base: &str) -> String { let base = format!("{}{base}", self.id_prefix); - let mut cand = base.clone(); - for i in 2.. { + if self.used_ids.insert(base.clone()) { + return base; + } + let next = self.next_suffix.entry(base.clone()).or_insert(2); + loop { + let cand = format!("{base}-{next}"); + *next += 1; if self.used_ids.insert(cand.clone()) { - break; + return cand; } - cand = format!("{base}-{i}"); } - cand } /// Reserve ids for every named object up front so generated def ids never collide with them. @@ -1584,7 +1578,7 @@ impl Writer<'_> { return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); } let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); - if !self.linked.iter().any(|l| l.name == name) { + if self.linked_names.insert(name.clone()) { self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); } name @@ -1869,8 +1863,12 @@ impl Writer<'_> { let resolved = doc.inline_resolved(t); let t = &*resolved; let inline = t.runs.iter().any(|r| r.inline.is_some()); - // Ruby is written as outlines with its base text: a reader's can't place it. - let outlined = self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })); + // Ruby is written as outlines with its base text: a reader's can't place it. So are + // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). + let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) + || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + let outlined = + self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); // Live type with an inside or outside character stroke writes that stroke apart from the @@ -1920,11 +1918,11 @@ impl Writer<'_> { } /// Text as glyph outlines: one compound path per run, painted like the run (gradients span - /// the whole text, as on the canvas). + /// the whole text), over its colour glyphs, as on the canvas. fn text_outlines(&mut self, n: &Node, t: &TextObject) { let db = vectorcraft_text::FontDb::global(); let lay = vectorcraft_text::layout(db, t); - let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted // with the run of the text they annotate. let bars = vectorcraft_text::decorations(&lay, db, t); @@ -1935,9 +1933,8 @@ impl Writer<'_> { .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) .chain(bars.iter().map(|(r, b)| (*r, b))) { - match runs.iter_mut().find(|(r, _)| *r == run) { - Some((_, bp)) => bp.extend(outline.iter()), - None => runs.push((run, outline.clone())), + if let Some(bp) = runs.get_mut(run) { + bp.extend(outline.iter()); } } let id = self.id_attr(n); @@ -1947,29 +1944,33 @@ impl Writer<'_> { // The glyphs stay in text space; the text's transform joins the document's meanwhile. let saved = self.xf; self.xf = saved * t.xf; - for (r, bp) in runs { - let Some(run) = t.runs.get(r) else { continue }; - let pd = PathData::from_bezpath(&bp); + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as + // their images, COLR and SVG glyphs as their art. + for g in lay.glyphs.iter().filter(|g| g.run == i) { + if let Some((image, to_text)) = g.placed_image() { + let m = self.matrix(self.xf * to_text); + let href = self.blob_href(&image.blob); + self.line(&format!( + "", + image.width, + image.height, + xml_escape(&href) + )); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(&art); + } + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(bp); // An unnamed id: the pieces carry no id attribute (the group has it). let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); let d = self.path_d(&pd, self.xf); self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds)); } - // Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint. - for (image, to_text) in lay.glyphs.iter().filter_map(|g| g.placed_image()) { - let m = self.matrix(self.xf * to_text); - let href = self.blob_href(&image.blob); - self.line(&format!( - "", - image.width, - image.height, - xml_escape(&href) - )); - } - // COLR glyphs: their layers, as art. - for art in lay.glyphs.iter().filter_map(|g| g.placed_colour(Affine::IDENTITY)) { - self.node(&art); - } self.xf = saved; self.depth -= 1; self.line(""); @@ -2002,6 +2003,11 @@ impl Writer<'_> { self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); return self.text_outlines(n, t); } + // A `` holds the characters of the text, not the bullets drawn before them. + if !lay.markers.is_empty() { + self.warn("type on a path with bullets is outlined to keep its look"); + return self.text_outlines(n, t); + } return self.text_on_path(n, t, path, *start); } self.note_fonts(t, &lay); @@ -2029,7 +2035,13 @@ impl Writer<'_> { let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); // The element's user space is text space. let space = (lay.bounds, Affine::IDENTITY); - let base = TextBase { props: self.char_props(&drawn.first_style(), space), space, anchored: uniform.is_some() }; + let mut base_props = self.char_props(&drawn.first_style(), space); + // A `` cannot undo the decoration of the `` around it: with bullets, which + // have none, each run's `` carries its own. + if !lay.markers.is_empty() { + base_props.retain(|(k, _)| *k != "text-decoration"); + } + let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; let mut props = base.props.clone(); if let Some((a, _)) = uniform { props.push(("text-anchor", a.into())); @@ -2113,7 +2125,14 @@ impl Writer<'_> { // The baseline shift the current text position carries. let mut shift = 0.0; for seg in line { - let Some(st) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let marker_style; + let st = if seg.marker { + marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); + &marker_style + } else { + run_style + }; let mut attrs = String::new(); if place { let (x, y) = if first { start } else { (seg.x, seg.y) }; @@ -2161,7 +2180,7 @@ impl Writer<'_> { let db = FontDb::global(); let plain = t.plain_text(); let mut face: Option> = None; - for g in &lay.glyphs { + for (i, g) in lay.glyphs.iter().enumerate() { let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the // viewer. @@ -2185,8 +2204,9 @@ impl Writer<'_> { self.fonts.len() - 1 }); let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue }; - // As written: a hyphen where a line breaks, capitals for All Caps, the style's Digits. - let src = if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; + // As written: a list marker's characters, a hyphen where a line breaks, capitals for All + // Caps, the style's Digits. + let src = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }); for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) { if st.all_caps { chars.extend(c.to_uppercase()); @@ -2404,6 +2424,9 @@ struct Segment { /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to /// (`textLength`); 1 for other segments. scale: f64, + /// A list marker's characters and tab, written in + /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. + marker: bool, } /// How far a reader may draw a character from where the layout draws it before the character is @@ -2440,18 +2463,24 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let mut tail = None; let mut segs: Vec = Vec::new(); let mut cluster = None; - let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; + // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). + let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { + lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) + }; let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { - while let Some((_, rest)) = glyphs.split_last().filter(|(g, _)| source(g).chars().all(|c| c.is_whitespace() && c != '\t')) { + while let Some((_, rest)) = + glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) + { glyphs = rest; } } let mut after_inline = false; - for g in glyphs { + for (k, g) in glyphs.iter().enumerate() { let Some(run) = t.runs.get(g.run) else { continue }; - if run.inline.is_some() { + // A list marker before an inline graphic is in the graphic's run, and is text. + if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { // An inline graphic (written as art): the text after it is placed anew. if let Some(last) = segs.last_mut() { last.brk = true; @@ -2470,7 +2499,9 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - } continue; } - cluster = Some(g.byte); + // A glyph that stands for no character (a list marker before the paragraph's first + // character) starts no cluster. + cluster = (g.len > 0).then_some(g.byte); let (advance, x) = match set { Some(&(advance, offset)) => { // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. @@ -2479,7 +2510,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - } None => (g.advance, g.origin.x), }; - let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); + let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); let piece = if run.style.all_caps { src.to_uppercase() } else { src }; let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. @@ -2489,18 +2520,23 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let moved = reader.is_some() && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); pen = x + advance; + // A list marker is a segment of its own, and so is the text after it. In horizontally + // scaled type a tab is a segment apart from the characters before it, so a reader + // stretching those characters to their width (`textLength`) stretches them alone. + let marker = lay.marker_of(line.glyph_start + k).is_some(); + let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh && !moved => { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { last.text.push_str(&piece); last.advance += advance; last.brk = brk; } last => { if let Some(last) = last { - last.brk |= moved; + last.brk |= moved || last.marker != marker; end_segment(last, tail, t, lay, &mut unkerned); } - segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale }); + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); } } tail = set.map(|_| (g.byte, advance)); diff --git a/crates/svg/src/glyph.rs b/crates/svg/src/glyph.rs index 060c11229..601ca2e63 100644 --- a/crates/svg/src/glyph.rs +++ b/crates/svg/src/glyph.rs @@ -1,17 +1,14 @@ //! OpenType SVG glyphs: the art of a font's glyph from the SVG document its `SVG ` table holds for //! it, made as the importer makes art of any SVG file. The text crate asks for it through the //! reader the app registers ([`vectorcraft_text::set_svg_glyph_reader`]). -//! -//! Where it differs from the `SVG ` table's rules: a size on the root (`width`, `height`) counts -//! for nothing; a non-square `viewBox` is scaled to fit the em by its larger side, not centred in -//! it; and a glyph element nested in others keeps what they apply (transforms, opacity). +use std::ops::Range; use std::sync::Arc; use std::sync::atomic::AtomicU64; use usvg::roxmltree; use vectorcraft_doc::color::Color; -use vectorcraft_doc::{Node, NodeId}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; use crate::ImportOptions; @@ -19,17 +16,17 @@ use crate::ImportOptions; /// The most bytes a glyph's document may have, unpacked. const MAX_SVG_GLYPH_BYTES: u64 = 1 << 20; -/// Elements left out of a glyph: text (type inside type), images (a glyph's art has no document -/// to keep them in), embedded content and scripts. The `SVG ` table rules text, embedded content -/// and scripts out, and allows images. -const LEFT_OUT: [&str; 4] = ["text", "image", "foreignObject", "script"]; +/// Elements left out of a glyph, as the `SVG ` table rules them out: text (type inside type), +/// embedded content and scripts (and images of SVG documents, in [`glyph`]). +const LEFT_OUT: [&str; 3] = ["text", "foreignObject", "script"]; /// Glyph `gid`'s art from its SVG `document` (possibly gzipped), in a font of `upem` units per em: /// its element `glyph` (or the whole document when that is its root), in font units (y down), -/// a `viewBox` mapped onto the em square. The text's colour is `fill`: `currentColor`, and the -/// legacy `context-fill` and `context-stroke` (none without a `stroke`). Its nodes have no ids -/// (they aren't a document's). With the art, whether it uses the text's colours. `None` when the -/// document doesn't read, is too large or has no such element. +/// the root's `viewBox` (or else its size) fitted into the em square by its larger side, centred. +/// The text's colour is `fill`: `currentColor`, and the legacy `context-fill` and `context-stroke` +/// (none without a `stroke`). Its nodes have no ids and its images their own pixels +/// ([`own_art`]). With the art, whether it uses the text's colours. `None` when the document doesn't read, is too large or has +/// no such element. pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Option) -> Option<(Node, bool)> { // The SVGZ budget left is the glyph's cap. let unpacked = AtomicU64::new(crate::MAX_SVGZ.saturating_sub(MAX_SVG_GLYPH_BYTES)); @@ -42,7 +39,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti let [r, g, b, _] = c.to_rgba8(1.0); format!("#{r:02x}{g:02x}{b:02x}") }; - let Glyph { svg, whole, view_box } = glyph(&text, &id, &hex(fill))?; + let Glyph { svg, whole, frame } = glyph(&text, &id, &hex(fill))?; let coloured = ["currentColor", "currentcolor", "context-fill", "context-stroke"].iter().any(|k| svg.contains(k)); // usvg resolves `context-fill` and `context-stroke` only for markers. let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex)); @@ -50,7 +47,7 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti let mut art = if whole { Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect()) } else { - // A top-level element is a layer, others an object, named after its id. + // The glyph, top-level, is a layer when a ``, else an object named after its id. let named = |n: &Node| n.name.as_deref() == Some(id.as_str()); match doc.layers.iter().find(|l| named(l)) { Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()), @@ -67,12 +64,12 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti } } }; - // Without a size of its own, the document's viewport is its view box at one unit per unit: - // the em square is that box. - if let Some(side) = view_box.filter(|s| *s > 0.0 && s.is_finite()) { - art.transform(Affine::scale(upem / side), true); + // Without a size of its own, the document's viewport is its frame at one unit per unit. + if let Some((w, h)) = frame.filter(|(w, h)| *w > 0.0 && *h > 0.0 && w.is_finite() && h.is_finite()) { + let s = upem / w.max(h); + art.transform(Affine::translate(((upem - w * s) / 2.0, (upem - h * s) / 2.0)) * Affine::scale(s), true); } - Some((anonymous(&art), coloured)) + Some((own_art(&art, &doc)?, coloured)) } /// A glyph's document made ready for the importer. @@ -80,28 +77,34 @@ struct Glyph { svg: String, /// The glyph is the whole document (its root has the glyph's id). whole: bool, - /// The larger side of the root's `viewBox`, if it has one. - view_box: Option, + /// The width and height of the root's `viewBox`, or else of its size, if it has either. + frame: Option<(f64, f64)>, } -/// `svg` with only what glyph `id` draws: without the other glyphs' top-level elements (a font -/// may keep all its glyphs in one document), the [`LEFT_OUT`] elements and event attributes, the -/// root without a size (the em square is its view box) and in the text's colour `color`. `None` -/// when it doesn't read or has no element `id`. +/// `svg` with only what glyph `id` draws, as if the root `use`d it: the rest of the document (a +/// font may keep all its glyphs in one) only defines what it refers to, and what its parents apply +/// doesn't reach it. Without the [`LEFT_OUT`] elements and event attributes, the root without a +/// size (the em square is its frame) and in the text's colour `color`. `None` when it doesn't read +/// or has no element `id`. fn glyph(svg: &str, id: &str, color: &str) -> Option { + // Before the XML reader: it nests by recursion and expands entities without a total limit. + if crate::import::xml_too_deep(svg) || crate::entities::expansion_too_large(svg) { + return None; + } let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).ok()?; let root = xml.root_element(); let whole = root.attribute("id") == Some(id); - if !whole && !xml.descendants().any(|n| n.attribute("id") == Some(id)) { - return None; - } - let is_glyph = |v: &str| v.strip_prefix("glyph").is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())); + let target = if whole { root } else { xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))? }; + let at = target.range(); // Edits: byte ranges to cut and what to put in their place, in order. - let mut edits: Vec<(std::ops::Range, String)> = vec![]; - for n in root.children().filter(|n| n.attribute("id").is_some_and(|v| v != id && is_glyph(v))) { - edits.push((n.range(), String::new())); - } - for n in root.descendants().filter(|n| n.is_element() && LEFT_OUT.contains(&n.tag_name().name())) { + let mut edits: Vec<(Range, String)> = vec![]; + // Cut with the left-out elements: images of SVG documents, as the table asks (a glyph draws no + // documents inside it). Their link is `href`, else `xlink:href`, as the importer reads it. + let svg_image = |n: &roxmltree::Node| { + let href = n.attribute("href").or_else(|| n.attribute(("http://www.w3.org/1999/xlink", "href"))); + n.tag_name().name() == "image" && href.and_then(|h| h.trim_start().get(..14)).is_some_and(|v| v.eq_ignore_ascii_case("data:image/svg")) + }; + for n in root.descendants().filter(|n| n.is_element() && (LEFT_OUT.contains(&n.tag_name().name()) || svg_image(n))) { edits.push((n.range(), String::new())); } for a in root.attributes().filter(|a| matches!(a.name(), "width" | "height" | "color")) { @@ -115,37 +118,75 @@ fn glyph(svg: &str, id: &str, color: &str) -> Option { let start = root.range().start + 1; let name = start + svg.get(start..)?.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?; edits.push((name..name, format!(" color=\"{color}\""))); + // The namespaces the glyph's parents declare, which it leaves: the root declares them too. + for ns in target.namespaces() { + if let Some(prefix) = ns.name().filter(|p| root.lookup_namespace_uri(Some(p)).is_none()) { + edits.push((name..name, format!(" xmlns:{prefix}=\"{}\"", crate::xml_escape(ns.uri())))); + } + } edits.sort_by_key(|(r, _)| (r.start, r.end)); - let mut out = String::with_capacity(svg.len()); - let mut at = 0; - for (r, with) in edits { + let out = if whole { + edited(svg, 0..svg.len(), &edits)? + } else { + // The root's content goes in `defs`, and the glyph after it, on its own. + let content = root.first_child()?.range().start; + let close = svg.get(..root.range().end)?.rfind("".into(), + edited(svg, content..at.start, &edits)?, + edited(svg, at.end..close, &edits)?, + "".into(), + edited(svg, at.clone(), &edits)?, + edited(svg, close..svg.len(), &edits)?, + ] + .concat() + }; + let number = |v: &str| v.trim().trim_end_matches("px").parse::().ok(); + let view_box = root.attribute("viewBox").and_then(|v| { + let n: Vec = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect(); + match n.as_slice() { + [_, _, w, h] => Some((*w, *h)), + _ => None, + } + }); + let size = || Some((number(root.attribute("width")?)?, number(root.attribute("height")?)?)); + Some(Glyph { svg: out, whole, frame: view_box.or_else(size) }) +} + +/// `svg[range]` with the `edits` (in order) that lie in it made: one around the range (a left-out +/// element holding the glyph) isn't, so it stays whole, defining only. +fn edited(svg: &str, range: Range, edits: &[(Range, String)]) -> Option { + let mut out = String::new(); + let mut at = range.start; + for (r, with) in edits.iter().filter(|(r, _)| range.start <= r.start && r.end <= range.end) { // An element inside one already cut goes with it. if r.start < at { continue; } out.push_str(svg.get(at..r.start)?); - out.push_str(&with); + out.push_str(with); at = r.end; } - out.push_str(svg.get(at..)?); - let view_box = root.attribute("viewBox").and_then(|v| { - let n: Vec = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect(); - match n.as_slice() { - [_, _, w, h] => Some(w.max(*h)), - _ => None, - } - }); - Some(Glyph { svg: out, whole, view_box }) + out.push_str(svg.get(at..range.end)?); + Some(out) } -/// `n` with no ids, its members' neither. -fn anonymous(n: &Node) -> Node { +/// `n` as art of its own, made from `doc` (the glyph's document): no ids (they aren't a document's), +/// its members' neither, and its images holding the pixels `doc` keeps for them, named by their +/// content. A linked image, or one whose pixels `doc` doesn't hold, is left out (`None` when `n` +/// is one). +fn own_art(n: &Node, doc: &Document) -> Option { let mut n = n.clone(); n.id = NodeId(u64::MAX); - if let Some(children) = n.children_mut() { - for c in children.iter_mut() { - *c = Arc::new(anonymous(c)); - } + if let NodeKind::Image(im) = &mut n.kind { + // A glyph's images are embedded: a link (to no file of the font's) shows nothing. + let blob = doc.images.get(&im.key).filter(|b| im.link.is_none() && !b.bytes.is_empty())?; + im.key = blob.content_key(); + im.pixels = Some(Arc::new(blob.clone())); } - n + if let Some(children) = n.children_mut() { + *children = children.iter().filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); + } + Some(n) } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 6dce3a2d9..aa827d762 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -34,6 +34,9 @@ mod vector_effect; /// The XML of `svg`. fn parse(svg: &str) -> Result, SvgError> { + if crate::entities::expansion_too_large(svg) { + return Err(SvgError::Parse("the entities the document declares expand to too much text".into())); + } roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) .map_err(|e| SvgError::Parse(e.to_string())) } @@ -342,6 +345,8 @@ fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option = appearance.into(); let mut n = if path.subpaths.len() > 1 { // Multi-subpath SVG paths are compound paths (as in Illustrator). let children = path @@ -1518,7 +1525,7 @@ impl Importer { .map(|sp| { let id = self.doc.alloc_id(); let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false }); - c.appearance = appearance.clone().into(); + c.appearance = appearance.clone(); Arc::new(c) }) .collect(); @@ -1526,7 +1533,7 @@ impl Importer { } else { self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false }) }; - n.appearance = appearance.into(); + n.appearance = appearance; Some(n) } @@ -1557,7 +1564,10 @@ impl Importer { if link.is_some() && !missing { blob.with_proxy() } else { blob } }); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); - Some(self.named(i.id(), NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default() })))) + Some(self.named( + i.id(), + NodeKind::Image(Box::new(ImageObject { key, width: pw, height: ph, xf, link, placement: Default::default(), pixels: None })), + )) } /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index f3ecbc862..e4732f8cd 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -116,7 +116,7 @@ fn target(h: &str, folder: Option<&str>) -> (String, Option) { }); match craft_docpath::resolve(&stored, document.as_deref()) { Ok(path) => { - let file = path.is_absolute().then(|| path.to_string_lossy().into_owned()); + let file = craft_docpath::resolve_absolute(&stored, document.as_deref()).ok().map(|p| p.to_string_lossy().into_owned()); let as_written = !file_url && craft_docpath::resolve(&stored, None).as_ref() == Ok(&path); let link = if as_written { stored } else { path.to_string_lossy().into_owned() }; (link, file) diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 0c4408f58..1b2f8ddb5 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -83,7 +83,16 @@ enum V { Stack(Vec), } +/// The most layers a filter result may stack: a result combined with itself again and again +/// would otherwise double each time. A filter past it is left as it is (not editable). +const MAX_LAYERS: usize = 64; + impl V { + /// `layers` stacked, `None` past [`MAX_LAYERS`]. + fn stack(layers: Vec) -> Option { + (layers.len() <= MAX_LAYERS).then_some(V::Stack(layers)) + } + fn layers(self) -> Vec { match self { V::Stack(l) => l, @@ -207,7 +216,7 @@ fn eval(f: &Filter, k: f64) -> Option { } else if transparent(&a) { b_in } else if b.mode() == usvg::BlendMode::Normal { - V::Stack(b_in.layers().into_iter().chain(a.layers()).collect()) + V::stack(b_in.layers().into_iter().chain(a.layers()).collect())? } else { return None; } @@ -223,7 +232,7 @@ fn eval(f: &Filter, k: f64) -> Option { } else if transparent(&a) { b } else { - V::Stack(b.layers().into_iter().chain(a.layers()).collect()) + V::stack(b.layers().into_iter().chain(a.layers()).collect())? } } _ => return None, @@ -242,7 +251,7 @@ fn eval(f: &Filter, k: f64) -> Option { match layers.len() { 0 => return None, 1 => layers.remove(0), - _ => V::Stack(layers), + _ => V::stack(layers)?, } } Kind::ColorMatrix(c) => match c.kind() { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index e647fcbcc..b292ce53e 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -95,6 +95,7 @@ #![forbid(unsafe_code)] mod css; +mod entities; mod export; mod glyph; mod import; @@ -378,7 +379,7 @@ pub struct Editing { /// The native document an SVG carries, if any, and whether the SVG was edited since. pub fn editing(svg: &str) -> Option { // (An SVG nested too deep for the XML reader carries none: it isn't one of ours.) - if !svg.contains(EDITING_NS) || import::xml_too_deep(svg) { + if !svg.contains(EDITING_NS) || import::xml_too_deep(svg) || entities::expansion_too_large(svg) { return None; } let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; diff --git a/crates/svg/tests/blend_export.rs b/crates/svg/tests/it/blend_export.rs similarity index 97% rename from crates/svg/tests/blend_export.rs rename to crates/svg/tests/it/blend_export.rs index ffe510524..65fb26799 100644 --- a/crates/svg/tests/blend_export.rs +++ b/crates/svg/tests/it/blend_export.rs @@ -1,7 +1,5 @@ //! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the //! reference formulas when resvg draws the file, and masks take luminance the canvas's way. -// 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 vectorcraft_color::blend::blend_rgb; use vectorcraft_color::{BlendMode, Color, Paint}; diff --git a/crates/svg/tests/colour_glyphs.rs b/crates/svg/tests/it/colour_glyphs.rs similarity index 82% rename from crates/svg/tests/colour_glyphs.rs rename to crates/svg/tests/it/colour_glyphs.rs index 987c362f0..03de592d8 100644 --- a/crates/svg/tests/colour_glyphs.rs +++ b/crates/svg/tests/it/colour_glyphs.rs @@ -1,7 +1,5 @@ //! Colour bitmap glyphs (emoji): outlined text writes their images; live text leaves them to the //! viewer, and embedded fonts don't carry them. -// 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 skrifa::raw::TableProvider; use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; @@ -43,14 +41,14 @@ fn embedded_fonts_leave_bitmap_glyphs_to_the_viewer() { assert!(out.svg.contains(&format!("{COLOUR_CHAR}F")), "the character stays text"); } -/// `ch` in the COLR test font (v1 or v0), 24 pt, filled blue. -fn colr_doc(v1: bool, ch: char) -> Document { +/// `text` in the COLR test font (v1 or v0), 24 pt, filled blue. +fn colr_doc(v1: bool, text: impl std::fmt::Display) -> Document { use vectorcraft_text::test_fonts::{colr_family, colr_font}; vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap()); let mut d = Document::new(200.0, 100.0); let fill = vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb8(0, 0, 255)); let st = CharStyle { font_family: colr_family(v1).into(), size: 24.0, fill, ..CharStyle::default() }; - let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &ch.to_string(), st)))); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &text.to_string(), st)))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d @@ -82,3 +80,15 @@ fn embedded_fonts_leave_colr_glyphs_to_the_viewer() { assert!(out.warnings.iter().any(|w| w.contains("emoji")), "{:?}", out.warnings); assert!(out.svg.contains(COLOUR_CHAR), "the character stays text"); } + +/// Outlined text stacks as the canvas paints type: a run's colour glyphs under its paint, so a +/// plain character set over a colour one stays on top. +#[test] +fn outlined_colour_glyphs_stay_under_their_runs_paint() { + let outlined = ExportOptions { outline_text: true, ..Default::default() }; + let svg = export_full(&doc(), &outlined, None).svg; + assert!(svg.find("').unwrap()].contains("f00"), "the red layer first: {svg}"); +} diff --git a/crates/svg/tests/container_export.rs b/crates/svg/tests/it/container_export.rs similarity index 93% rename from crates/svg/tests/container_export.rs rename to crates/svg/tests/it/container_export.rs index 0a69c8a54..5f34b6cc3 100644 --- a/crates/svg/tests/container_export.rs +++ b/crates/svg/tests/it/container_export.rs @@ -1,8 +1,6 @@ //! The appearance of groups and layers in SVG export: their own fills and strokes are baked into //! paths painting the members (below or above them as the contents slot says), their geometry //! effects into the members, and their raster effects filter the whole group. -// 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::sync::Arc; @@ -70,7 +68,15 @@ fn group_effects_bake_into_the_members_and_filter_the_group() { fn a_group_transform_copies_its_images() { let mut d = Document::new(200.0, 100.0); d.images.insert("red".into(), ImageBlob::new("image/png", vec![1, 2, 3])); - let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "red".into(), + width: 20, + height: 20, + xf: Affine::translate((20.0, 20.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))); let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); let square = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 60.0, 30.0, 70.0)), black)); diff --git a/crates/svg/tests/css_rules.rs b/crates/svg/tests/it/css_rules.rs similarity index 98% rename from crates/svg/tests/css_rules.rs rename to crates/svg/tests/it/css_rules.rs index 48e0747ca..0a7633db4 100644 --- a/crates/svg/tests/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -1,8 +1,6 @@ //! CSS for objects (the CSS Properties panel): shapes give backgrounds, borders and corner radii, //! type its font, gradients `linear-gradient()`/`radial-gradient()`, and every property both write //! reads as SVG export writes it. -// 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 vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Justify, LiveShape, Node, NodeId, NodeKind, TextObject}; diff --git a/crates/svg/tests/dash_align.rs b/crates/svg/tests/it/dash_align.rs similarity index 91% rename from crates/svg/tests/dash_align.rs rename to crates/svg/tests/it/dash_align.rs index edb047b1b..2885d3744 100644 --- a/crates/svg/tests/dash_align.rs +++ b/crates/svg/tests/it/dash_align.rs @@ -1,7 +1,5 @@ //! Dashes fitted to corners export as the canvas's filled outlines (a dash array can't fit them); //! exact dashes stay a `stroke-dasharray`. Checked by rendering the exported SVG with resvg. -// 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 vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, StrokeLayer}; diff --git a/crates/svg/tests/data_attributes.rs b/crates/svg/tests/it/data_attributes.rs similarity index 99% rename from crates/svg/tests/data_attributes.rs rename to crates/svg/tests/it/data_attributes.rs index 762404816..981662c7c 100644 --- a/crates/svg/tests/data_attributes.rs +++ b/crates/svg/tests/it/data_attributes.rs @@ -1,6 +1,5 @@ //! Synthetic XML 1.0/SVG round trips: https://www.w3.org/TR/xml/#AVNormalize //! https://www.w3.org/TR/xml/#NT-NameChar https://www.w3.org/TR/xml/#charsets -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::collections::HashSet; use usvg::roxmltree; use vectorcraft_doc::{Document, Node}; diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/it/effects_export.rs similarity index 96% rename from crates/svg/tests/effects_export.rs rename to crates/svg/tests/it/effects_export.rs index 800d00e1d..26154045a 100644 --- a/crates/svg/tests/effects_export.rs +++ b/crates/svg/tests/it/effects_export.rs @@ -1,7 +1,5 @@ //! Live effects in SVG export: a fill or stroke's own raster effects become filters on that item's //! element, and geometry effects on type are baked through its outlines. -// 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 serde_json::json; use vectorcraft_color::{Color, Paint}; diff --git a/crates/svg/tests/encoding_profile_fonts.rs b/crates/svg/tests/it/encoding_profile_fonts.rs similarity index 97% rename from crates/svg/tests/encoding_profile_fonts.rs rename to crates/svg/tests/it/encoding_profile_fonts.rs index 722e90b30..0cbb61b48 100644 --- a/crates/svg/tests/encoding_profile_fonts.rs +++ b/crates/svg/tests/it/encoding_profile_fonts.rs @@ -1,8 +1,6 @@ //! SVG Options → Encoding, SVG Profile and embedded fonts: UTF-16 and ISO 8859-1 files read //! back, entity styling parses in usvg, SVG Tiny 1.2 has no filters, masks, symbols or style //! sheets, and embedded fonts hold only the glyphs used where their licence allows it. -// 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::sync::Arc; @@ -287,7 +285,7 @@ fn variable_font_settings_are_written_embedded_and_read_back() { /// The bundled Source Sans 3 Regular renamed `family` (13 characters, as long as the original /// name) with OS/2 `fsType` set to `fs_type`. fn licensed_font(family: &str, fs_type: u16) -> Vec { - let mut data = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); + let mut data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); let table = |data: &[u8], tag: &[u8; 4]| { let f = skrifa::FontRef::new(data).unwrap(); let r = f.table_directory.table_records().iter().find(|r| r.tag().to_be_bytes() == *tag).unwrap(); @@ -320,7 +318,7 @@ fn font_licences_are_respected() { let fonts = embedded(&o.svg); assert_eq!(fonts.len(), 1, "{:?}", o.warnings); assert_eq!(fonts[0].0, "\"Wholed Sans 3\""); - let size = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").len(); + let size = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").len(); assert!(fonts[0].2.len() + 64 >= size, "embedded whole: {} of {size} bytes", fonts[0].2.len()); assert!(o.warnings.iter().any(|w| w.contains("Sealed Sans 3") && w.contains("doesn't allow embedding")), "{:?}", o.warnings); assert!(o.warnings.iter().any(|w| w.contains("Wholed Sans 3") && w.contains("whole")), "{:?}", o.warnings); diff --git a/crates/svg/tests/focal.rs b/crates/svg/tests/it/focal.rs similarity index 94% rename from crates/svg/tests/focal.rs rename to crates/svg/tests/it/focal.rs index e61914458..4d76faf40 100644 --- a/crates/svg/tests/focal.rs +++ b/crates/svg/tests/it/focal.rs @@ -1,6 +1,4 @@ //! Radial gradients' focal points as `fx`/`fy`, out and back in. -// 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 vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, Document, Node}; diff --git a/crates/svg/tests/font_weights.rs b/crates/svg/tests/it/font_weights.rs similarity index 94% rename from crates/svg/tests/font_weights.rs rename to crates/svg/tests/it/font_weights.rs index 31d358c61..0e1a41183 100644 --- a/crates/svg/tests/font_weights.rs +++ b/crates/svg/tests/it/font_weights.rs @@ -1,7 +1,5 @@ //! Font weights in SVG and CSS: every style is written with its numeric weight (Semibold 600, //! Light 300, Black 900…; Bold as `bold`), and SVG import reads the numbers back to the same face. -// 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 vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; use vectorcraft_geom::Point; diff --git a/crates/svg/tests/foreign_attributes.rs b/crates/svg/tests/it/foreign_attributes.rs similarity index 99% rename from crates/svg/tests/foreign_attributes.rs rename to crates/svg/tests/it/foreign_attributes.rs index 9f734d0e7..da949e09a 100644 --- a/crates/svg/tests/foreign_attributes.rs +++ b/crates/svg/tests/it/foreign_attributes.rs @@ -1,7 +1,6 @@ //! Attributes in other applications' XML namespaces (https://www.w3.org/TR/xml-names/) survive a //! round trip through VectorCraft: kept on import, written back on export with their namespaces //! declared on the root. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use usvg::roxmltree; use vectorcraft_doc::{Document, ForeignAttribute, Node, NodeKind}; use vectorcraft_svg::{ExportOptions, MAX_FOREIGN, MAX_FOREIGN_VALUE, export, export_full, import, import_with_report}; diff --git a/crates/svg/tests/gradients.rs b/crates/svg/tests/it/gradients.rs similarity index 96% rename from crates/svg/tests/gradients.rs rename to crates/svg/tests/it/gradients.rs index 9009316cd..efbbc30e6 100644 --- a/crates/svg/tests/gradients.rs +++ b/crates/svg/tests/it/gradients.rs @@ -1,7 +1,5 @@ //! Gradient defs: text gradients span the laid-out text, stroke gradients the stroked area, //! identical gradients share a def named after their swatch, and midpoints survive re-import. -// 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 vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, TextObject}; diff --git a/crates/svg/tests/ids.rs b/crates/svg/tests/it/ids.rs similarity index 96% rename from crates/svg/tests/ids.rs rename to crates/svg/tests/it/ids.rs index affd496ef..cbd2e834d 100644 --- a/crates/svg/tests/ids.rs +++ b/crates/svg/tests/it/ids.rs @@ -1,7 +1,5 @@ //! Object IDs: names survive the round trip (Layer Names), Minimal writes only referenced ids, //! Unique ids never collide, and other apps' label attributes become object names. -// 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::collections::HashSet; diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/it/import_details.rs similarity index 99% rename from crates/svg/tests/import_details.rs rename to crates/svg/tests/it/import_details.rs index cd308d785..9db57336d 100644 --- a/crates/svg/tests/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -1,8 +1,6 @@ //! SVG import details: linked images read from the SVG's folder and kept linked (a placeholder //! and a warning when missing), SVG images as vector art, nested clips, undisplayed objects as //! hidden objects, and reflected or repeated gradients. -// 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::path::{Path, PathBuf}; diff --git a/crates/svg/tests/import_live.rs b/crates/svg/tests/it/import_live.rs similarity index 99% rename from crates/svg/tests/import_live.rs rename to crates/svg/tests/it/import_live.rs index b327cbe5c..35b0c810d 100644 --- a/crates/svg/tests/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -1,8 +1,6 @@ //! SVG import keeps what the canvas edits live: `` becomes a pattern swatch, `` //! with `` a symbol and its instances, blurs and drop-shadow chains live effects; and //! export → import keeps them. -// 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::sync::Arc; diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/it/import_text.rs similarity index 99% rename from crates/svg/tests/import_text.rs rename to crates/svg/tests/it/import_text.rs index a65a33c69..78716ef6e 100644 --- a/crates/svg/tests/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -1,7 +1,5 @@ //! SVG `` import fidelity: placement in the tree, type on a path, per-character positions, //! paints and the style cascade. -// 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 vectorcraft_color::{GradientKind, Paint}; use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject}; diff --git a/crates/svg/tests/import_units.rs b/crates/svg/tests/it/import_units.rs similarity index 96% rename from crates/svg/tests/import_units.rs rename to crates/svg/tests/it/import_units.rs index b6fc271cc..1ce01c7f0 100644 --- a/crates/svg/tests/import_units.rs +++ b/crates/svg/tests/it/import_units.rs @@ -1,7 +1,5 @@ //! SVG import units: physical lengths keep their printed size, pixels and user units map 1:1 to //! points, and the document's units follow the file's. -// 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 vectorcraft_doc::{Document, Node, NodeKind, Unit}; use vectorcraft_geom::Rect; diff --git a/crates/svg/tests/interactivity.rs b/crates/svg/tests/it/interactivity.rs similarity index 99% rename from crates/svg/tests/interactivity.rs rename to crates/svg/tests/it/interactivity.rs index eb5cc3436..66b98959b 100644 --- a/crates/svg/tests/interactivity.rs +++ b/crates/svg/tests/it/interactivity.rs @@ -1,7 +1,6 @@ //! SVG Interactivity: event attributes (https://www.w3.org/TR/SVG11/script.html#EventAttributes) //! and `