egui037: migrate VectorCraft UI and reconcile current main

This commit is contained in:
skylartaylor
2026-10-25 03:27:29 +01:00
480 changed files with 27012 additions and 4580 deletions
+30
View File
@@ -0,0 +1,30 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev
+10 -16
View File
@@ -1,25 +1,19 @@
# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
branches: [release]
pull_request:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
concurrency:
@@ -35,5 +29,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
run-package-check: true
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: desktop-print
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
+4 -2
View File
@@ -13,7 +13,7 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
@@ -61,12 +61,14 @@ jobs:
release:
needs: git-sources
permissions:
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: vectorcraft
app-title: VectorCraft
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+7 -13
View File
@@ -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:
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
with:
toolchain: nightly-2026-10-01
components: rust-src
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: windows7-x64
- name: Build, check and test the portable package
+20 -17
View File
@@ -12,6 +12,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts |
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts |
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
@@ -177,6 +179,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-para-justify-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-ltr.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-numbers.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: the digits 1, 2 and 3, each before a line | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -349,20 +352,20 @@ The shared PDF backend keeps the existing standard-font and CMap features. These
| Crate / file | Author | Source | Licence | SHA-256 | Use |
| --- | --- | --- | --- | --- | --- |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
Generated
+1398 -159
View File
File diff suppressed because it is too large Load Diff
+42 -12
View File
@@ -11,13 +11,22 @@ rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] }
# Kept at the egui-0.37 port revision on purpose: 25da contains 4f85efb's bounded print
# parsing and 2bbef53's printer names; d991bf67 (VectorCraft main's pin) lacks both. This is a
# targeted security/UI bump, not an all-libs flatten. Re-check if main backports them.
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# egui 0.37 port. 25da has craft_ui::capture and the old docking surface
# (DockContent/show_customized; Floating {panels, active, rect}) that VectorCraft's
# panel_docking.rs uses. Do not bump this to the new-docking L revision without porting that file.
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
# The egui 0.37 port of craft-keymap. 25da's core keymap API matches 2eca9b3a; only its
# `ui` feature and tests differ, and VectorCraft enables only `egui`.
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -101,10 +110,10 @@ tiff = "0.11"
zune-jpeg = "0.5"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
clipboard-win = "5.4"
@@ -124,18 +133,39 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
# Workspace crates keep line tables, so panics and backtraces name file and line; dependencies
# carry no debug info. Full debug info would make the dev target dir several times larger.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+22 -4
View File
@@ -75,12 +75,12 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Shared PDF backend
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
are fetched from https://github.com/storytold/craft-libs at revision
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0
and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision
2eca9b3a53a98ac52efed2a27353c6a6927ead10. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/README.craft-reader.md.
Assets already bundled through Hayro's default features retain their licences:
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
@@ -123,3 +123,21 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bundled egui font icons
The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is
included through monochrome_emoji_fonts. Their upstream project is
https://github.com/jslegers/emoji-icon-font; both use the same exact copyright
and permission notice below.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -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};
@@ -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;
+7
View File
@@ -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;
@@ -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};
+37 -32
View File
@@ -106,6 +106,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
canvas,
web_options(),
Box::new(move |cc| {
VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
let ctx = &cc.egui_ctx;
crate::fonts::spawn_background_fonts(ctx.clone());
let loss = GraphicsLoss::default();
@@ -365,48 +366,52 @@ impl eframe::App for WebShell {
self.restart(&why);
}
let Some(app) = &mut self.app else { return };
let pasted = self.host.pasted.borrow_mut().flavour.take();
if let Some((f, held)) = pasted {
app.paste_from_host(ctx, f, held);
}
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place(d) => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
}
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
}
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
app.logic(ctx);
app.tick(ctx);
crate::api::poll_replies();
app.finish_ime_logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
raw.events.extend(self.host.pointers.borrow_mut().take());
if let Some(app) = &mut self.app {
app.raw_input_hook(raw);
app.raw_input_hook(ctx, raw);
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
if let Some(app) = &mut self.app {
// DOM paste and drop are host input: read them in the UI phase, once per pass, not in
// `logic` where a hidden tab replays the last shown frame's input.
let pasted = self.host.pasted.borrow_mut().flavour.take();
if let Some((f, held)) = pasted {
app.paste_from_host(&ctx, f, held);
}
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place(d) => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
}
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
}
repaint.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
app.ui(ui);
}
crate::api::poll_replies();
+3 -3
View File
@@ -23,7 +23,7 @@ vectorcraft-text = { workspace = true }
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
# so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217.
craft-window = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
eframe = { workspace = true, features = ["clipboard", "default_fonts", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -62,8 +62,8 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_U
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
# The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR
# Apache-2.0 OR Zlib, already in the build through eframe and winit.
+34
View File
@@ -67,10 +67,31 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
}
};
eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}");
#[cfg(target_os = "macos")]
keep_answering();
std::thread::spawn(move || accept_loop(listener, tx, ctx));
rx
}
/// The activity the control channel holds on macOS: work the user asked for, which may let the
/// system sleep when idle, with full timer precision. Without it, App Nap lowers every thread to
/// the lowest priority within about a minute of the window no longer being visible.
#[cfg(target_os = "macos")]
fn activity_options() -> objc2_foundation::NSActivityOptions {
use objc2_foundation::NSActivityOptions;
NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical
}
/// Begins the control channel's activity. It lasts until the process ends.
#[cfg(target_os = "macos")]
fn keep_answering() {
use objc2_foundation::{NSProcessInfo, NSString};
let reason = NSString::from_str("Answering control requests");
let activity = NSProcessInfo::processInfo().beginActivityWithOptions_reason(activity_options(), &reason);
// The token is never released or passed to `endActivity`, so the activity lasts for the process's lifetime.
std::mem::forget(activity);
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for stream in listener.incoming().flatten() {
@@ -642,4 +663,17 @@ mod tests {
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
// A test process is not an app that App Nap applies to, so this checks the activity the
// channel asks for; the throttling itself shows only in a running app with its window hidden.
#[cfg(target_os = "macos")]
#[test]
fn the_control_channel_keeps_the_app_at_full_priority_on_macos() {
use objc2_foundation::NSActivityOptions;
let options = activity_options();
assert!(options.contains(NSActivityOptions::LatencyCritical), "without it App Nap still lowers the priority to 20");
assert!(options.contains(NSActivityOptions::UserInitiatedAllowingIdleSystemSleep));
assert!(!options.contains(NSActivityOptions::IdleSystemSleepDisabled), "the channel must not keep the Mac awake");
keep_answering();
}
}
+275
View File
@@ -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:
//! <https://developer.apple.com/documentation/appkit/nsevent/addlocalmonitorforevents(matching:handler:)>)
//! 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<PenSample>),
/// 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<StylusFeed> {
// 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<NSEvent>| -> *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");
}
}
+60 -6
View File
@@ -41,6 +41,7 @@ mod mac_menu;
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
mod mac_tablet;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
@@ -88,8 +89,9 @@ impl eframe::App for App {
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
file_access::unconfined(|| self.logic_frame(ctx, frame));
self.app.finish_ime_logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
@@ -102,7 +104,7 @@ impl eframe::App for App {
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(all(windows, not(target_vendor = "win7")))]
windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(raw));
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -163,7 +165,7 @@ impl App {
}
open_files(&mut self.app, files);
}
self.app.logic(ctx);
self.app.tick(ctx);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
@@ -309,15 +311,20 @@ fn save_prefs(app: &VectorcraftApp) {
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
if let Ok(bytes) = prefs_bytes(app) {
// Preferences are best effort: a failed write keeps the previous file.
let _ = fileio::write_atomic(&p, &bytes);
}
}
}
/// The preferences file's contents: the UI state with the engine preferences.
fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
serde_json::to_vec_pretty(&ui)
}
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
@@ -580,6 +587,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
no_cursor_theme: wayland && cursor_theme::theme_missing(),
#[cfg(target_os = "linux")]
stylus: if x11 { tablet::start() } else { None },
// macOS reads it from AppKit's events (#852).
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
@@ -1005,6 +1015,7 @@ fn main() -> std::process::ExitCode {
run(
options,
Box::new(move |cc| {
VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
let mut session = Session::new();
// The profiles loaded before come back before the preferences name any of them.
prefs_dir::attach_profile_store(&mut session);
@@ -1140,6 +1151,38 @@ mod tests {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
/// Shortcut overrides an earlier version saved still load: its spellings, `panel:` keys, an
/// override that can't fire (#487), an entry for a command this version lacks, and an old set
/// name.
#[test]
fn shortcut_overrides_from_an_earlier_version_load() {
use serde_json::json;
use vectorcraft_ui_egui::{keymap, shortcut_editor};
let mut raw = serde_json::to_value(vectorcraft_ui_egui::UiState::default()).unwrap();
raw["shortcut_overrides"] = json!({
"tool:pen": "Shift+9",
"edit.preferences": "",
"panel:layers": "Cmd+Alt+Shift+F7",
"tool:groupSelection": "Shift+ShiftLeft",
"test.unknown": "Cmd+Alt+Shift+F9",
"view.grid": "Cmd+Period",
});
raw["shortcut_set"] = json!(shortcut_editor::LEGACY_PRESETS[0].0);
let ui = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap().sanitized();
assert_eq!(ui.shortcut_set, shortcut_editor::LEGACY_PRESETS[0].1);
let km = keymap::keymap_with(&ui.shortcut_overrides);
let text = |key: &str| keymap::effective_with(&km, key);
assert_eq!(text("tool:pen").as_deref(), Some("Shift+9"));
assert_eq!(text("edit.preferences"), None);
assert_eq!(text("panel:layers").as_deref(), Some("Cmd+Alt+Shift+F7"));
assert_eq!(text("tool:groupSelection").as_deref(), shortcut_editor::default_of("tool:groupSelection"), "back on its default");
assert_eq!(text("view.grid").as_deref(), Some("Cmd+Period"));
let period = keymap::vc_chord("Cmd+.").unwrap();
assert_eq!(keymap::lookup(&km, &period).map(|c| c.id), Some("view.grid".to_string()));
assert_eq!(text("test.unknown").as_deref(), Some("Cmd+Alt+Shift+F9"), "kept");
assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported");
}
#[test]
fn opening_files_unminimizes_and_focuses_the_window() {
let ctx = egui::Context::default();
@@ -1201,6 +1244,17 @@ mod tests {
);
}
#[test]
fn the_image_trace_preview_setting_is_kept_across_a_restart() {
for preview in [false, true] {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.ui.image_trace_preview = preview;
let saved = prefs_bytes(&app).unwrap();
let loaded = decode_saved_prefs(&saved).unwrap().sanitized();
assert_eq!(loaded.image_trace_preview, preview, "Preview {preview} after a restart");
}
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
use craft_window::{Frame, Layout};
+16
View File
@@ -23,6 +23,11 @@ impl PrintService for SystemPrint {
fn shared_print(&self) -> Option<vectorcraft_ui_egui::print::SharedPrintFn> {
Some(std::sync::Arc::new(|job| SystemPrint.print(job)))
}
fn turns_landscape(&self) -> bool {
// macOS's `lp` doesn't turn a landscape page to the paper: it cuts it off on upright
// paper. craft-print turns each sheet to its own orientation on Windows.
!cfg!(target_os = "macos")
}
fn has_setup(&self) -> bool {
true
}
@@ -127,3 +132,14 @@ mod tests {
assert_eq!(args, ["ms-settings:printers"]);
}
}
#[cfg(test)]
mod turn_tests {
use super::SystemPrint;
use vectorcraft_ui_egui::print::PrintService;
#[test]
fn only_macos_queues_need_landscape_pages_turned() {
assert_eq!(SystemPrint.turns_landscape(), !cfg!(target_os = "macos"));
}
}
@@ -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};
@@ -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).
+6
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M3.6 4.2L5 3.2V8.8" stroke-width="1.2" /><path d="M3 10.5C3.3 9.6 6.6 9.4 6.6 10.9C6.6 12.1 3 13.1 3 14.6H6.8" stroke-width="1.2" /><path d="M3 15.6H6.4L4.7 17.6C6.6 17.5 7.1 20.6 4.8 20.6C4 20.6 3.4 20.2 3 19.7" stroke-width="1.2" /><path d="M9 6H21" stroke-width="1.5" /><path d="M9 12H21" stroke-width="1.5" /><path d="M9 18H21" stroke-width="1.5" /></svg>

After

Width:  |  Height:  |  Size: 551 B

+4 -4
View File
@@ -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`):
+7 -1
View File
@@ -215,7 +215,13 @@ fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
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<usize, f64> = 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();
+5 -1
View File
@@ -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));
+15 -1
View File
@@ -139,6 +139,14 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
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<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
@@ -170,7 +178,13 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
}
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 },
_ => {
+4
View File
@@ -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"));
}
+1 -1
View File
@@ -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;
@@ -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},
+20
View File
@@ -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;
@@ -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,
@@ -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<Image>) {
for node in nodes {
@@ -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<PathBuf>) {
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");
@@ -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();
+22
View File
@@ -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);
+4
View File
@@ -689,6 +689,10 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, 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<Vec<Node>> {
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;
+16 -1
View File
@@ -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<Track> {
let mut out = vec![];
@@ -165,8 +168,20 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
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) => {
+8 -1
View File
@@ -495,7 +495,14 @@ impl<'d, 'a> Build<'d, 'a> {
let path = PathData::from_bezpath(&bp);
let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false };
let mut n = if path.subpaths.len() > 1 {
// Several loops (a hatch with islands) paint as one compound path.
// Several loops (a hatch with islands) paint as one compound path. Each loop is an
// object of its own, so it counts against the node limit like any other (`item`
// counts the compound path itself).
if self.nodes.saturating_add(path.subpaths.len()) >= MAX_NODES {
self.warn(TOO_MANY);
return None;
}
self.nodes += path.subpaths.len();
let children = path
.subpaths
.into_iter()
+17 -4
View File
@@ -421,21 +421,34 @@ fn font_family(g: &[Pair<'_>]) -> String {
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
/// objects but with paper-space art has one paper layout.
/// The most bytes of layout and block names the layouts keep together. Each layout keeps its own
/// copy of its block's name, so many layouts naming one long block name multiplied it; real
/// drawings have a few dozen layouts with short names.
const MAX_LAYOUT_NAME_BYTES: usize = 4 << 20;
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
let mut paper: Vec<(i64, LayoutDef)> = vec![];
let mut seen = std::collections::HashSet::new();
let mut name_bytes = 0usize;
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
// The layout's own groups follow its subclass marker.
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
let Some(name) = own.name(1) else { continue };
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())).cloned();
if name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase()));
if name.eq_ignore_ascii_case(MODEL) || block.map(String::as_str) == Some(MODEL_BLOCK) {
continue;
}
let Some(block) = block else { continue };
if seen.insert(name.to_ascii_lowercase()) {
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) }));
let key = name.to_ascii_lowercase();
let cost = name.len() + block.len();
// A layout past the allowance is skipped alone: a later short one is still offered, and a
// skipped one reserves neither its name nor its bytes.
if seen.contains(&key) || name_bytes.saturating_add(cost) > MAX_LAYOUT_NAME_BYTES {
continue;
}
seen.insert(key);
name_bytes += cost;
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block.clone()) }));
}
paper.sort_by_key(|(order, _)| *order);
if paper.is_empty() && groups(entities).any(in_paper_space) {
+35 -5
View File
@@ -4,13 +4,13 @@
/// A single-line text value (TEXT, ATTRIB) as plain text.
pub(crate) fn plain(s: &str) -> String {
percent_codes(&escapes(s))
percent_codes(&escapes(s, false))
}
/// A multiline text value (MTEXT) as plain text with `\n` between paragraphs, and the font family
/// its first font change names.
pub(crate) fn mtext(s: &str) -> (String, Option<String>) {
let s = escapes(s);
let s = escapes(s, true);
let mut out = String::with_capacity(s.len());
let mut family = None;
let mut chars = s.chars().peekable();
@@ -25,7 +25,7 @@ pub(crate) fn mtext(s: &str) -> (String, Option<String>) {
Some('S') => {
// A stacked fraction `\Snum^den;` (or / or #): written as num/den.
let arg = until_semicolon(&mut chars);
out.push_str(&arg.replace(['^', '#'], "/"));
out.push_str(&arg.replace("^ ", "^").replace(['^', '#'], "/"));
}
Some(f @ ('f' | 'F')) => {
let arg = until_semicolon(&mut chars);
@@ -63,20 +63,38 @@ fn until_semicolon(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Stri
}
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped.
fn escapes(s: &str) -> String {
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. In
/// multiline text (`mtext`) `\\` is an escaped backslash and is kept as is, so that a `U+` after
/// it stays literal, and the `^` in a stacked fraction `\Snum^den;` is left for the caller.
fn escapes(s: &str, mtext: bool) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(c) = rest.chars().next() {
let after = rest.get(c.len_utf8()..).unwrap_or("");
if mtext && c == '\\' && after.starts_with('\\') {
out.push_str("\\\\");
rest = after.get(1..).unwrap_or("");
continue;
}
if c == '\\'
&& let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
&& hex.bytes().all(|b| b.is_ascii_hexdigit())
&& let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
{
out.push(ch);
rest = after.get(6..).unwrap_or("");
continue;
}
if mtext
&& c == '\\'
&& let Some(tail) = after.strip_prefix('S')
{
let end = tail.find(';').map_or(tail.len(), |i| i + 1);
out.push_str("\\S");
out.push_str(tail.get(..end).unwrap_or(""));
rest = tail.get(end..).unwrap_or("");
continue;
}
if c == '^'
&& let Some(next) = after.chars().next()
{
@@ -135,6 +153,14 @@ fn percent_codes(s: &str) -> String {
mod tests {
use super::*;
#[test]
fn unicode_escape_requires_four_hex_digits() {
assert_eq!(plain("\\U++041"), "\\U++041");
assert_eq!(plain("\\U+00G1"), "\\U+00G1");
assert_eq!(plain("\\U+ 041"), "\\U+ 041");
assert_eq!(plain("\\U+00C9"), "É");
}
#[test]
fn control_codes_and_escapes() {
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
@@ -152,6 +178,10 @@ mod tests {
// Carets are written "^ " (a caret before another character is a control character).
assert_eq!(mtext("x\\S3^ 4;").0, "x3/4");
assert_eq!(mtext("x\\S3#4;").0, "x3/4");
assert_eq!(mtext("\\S1^2;").0, "1/2");
assert_eq!(mtext("\\S+1^-2;^I").0, "+1/-2\t");
assert_eq!(mtext("trailing\\").0, "trailing\\");
assert_eq!(mtext("a\\\\U+0041b").0, "a\\U+0041b", "an escaped backslash is not a Unicode escape");
assert_eq!(mtext("a\\U+0041b").0, "aAb");
}
}
+7 -2
View File
@@ -909,8 +909,13 @@ impl<'a> Scene<'a> {
let Some(&first) = run.first() else { continue };
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| m.text.as_str());
let Some(s) = marker.or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
// A right-to-left paragraph's marker is drawn with its tab first and its number's
// period at the number's left; the TEXT entity holds the characters in that order.
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') {
Some(number) if m.tab == m.glyphs.start => format!(".{number}"),
_ => m.text.clone(),
});
let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue };
let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill };
let Some(rgb) = self.paint_rgb(paint) else { continue };
+24
View File
@@ -492,6 +492,30 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() {
assert!((all(&e[1], 10)[0] - bullet_x).abs() < 1e-6, "the bullet where it is drawn: {} and {bullet_x}", all(&e[1], 10)[0]);
}
/// A numbered list paragraph's number and period are a TEXT entity of their own where they are
/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the
/// number's left.
#[test]
fn a_number_is_text_of_its_own_before_its_paragraph() {
use vectorcraft_doc::text::{NumberStyle, ParaList};
let numbered = |text: &str, count: u32| {
let mut d = text_doc(text, 20.0);
let id = d.layers[0].children().unwrap()[0].id;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
t.para.left_indent = 30.0;
t.para.first_line_indent = -30.0;
t.list_count = count;
}
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>()
};
assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]);
let rtl = numbered("שלום", 11);
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");
}
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
#[test]
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
+36
View File
@@ -95,6 +95,9 @@ impl IccProfile {
}
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
if !localized_text_fits(bytes) {
return Err(CmsError::BadProfile(format!("its localized names take more than {} KB", MAX_LOCALIZED_TEXT >> 10)));
}
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? })
}
@@ -393,3 +396,36 @@ pub(super) fn gray_profile(name: &str) -> ColorProfile {
p.gray_trc = ColorProfile::new_srgb().red_trc;
labelled(p, name)
}
/// Most bytes of UTF-16 the localized names (`mluc` tags) of a profile may take together.
const MAX_LOCALIZED_TEXT: usize = 1 << 20;
/// Whether the localized-name records of the `mluc` tags in ICC `bytes` take at most
/// [`MAX_LOCALIZED_TEXT`] together. moxcms copies each record into a string of its own, and records
/// may all point at the same bytes, so a profile under a megabyte could make it allocate
/// gigabytes. A table it can't read is left to moxcms to refuse.
pub(crate) fn localized_text_fits(bytes: &[u8]) -> bool {
let be32 = |b: &[u8], at: usize| b.get(at..at.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
let Some(tags) = be32(bytes, 128) else { return true };
let mut total = 0usize;
for i in 0..tags {
let Some(entry) = i.checked_mul(12).and_then(|e| e.checked_add(132)) else { return false };
let (Some(offset), Some(size)) = (be32(bytes, entry + 4), be32(bytes, entry + 8)) else { break };
let Some(tag) = offset.checked_add(size).and_then(|end| bytes.get(offset..end)) else { continue };
if !tag.starts_with(b"mluc") {
continue;
}
let records = be32(tag, 8).unwrap_or(0);
for r in 0..records {
// Each record's header: language, country, length, offset.
let Some(length) = r.checked_mul(12).and_then(|h| be32(tag, h + 16 + 4)) else { break };
// Each record counts its 12-byte header too: records of length 0 are not free, so
// many tags pointing at one `mluc` can't make this loop quadratic.
total = total.saturating_add(length).saturating_add(12);
if total > MAX_LOCALIZED_TEXT {
return false;
}
}
}
true
}
+28
View File
@@ -152,3 +152,31 @@ fn cmyk_profiles_have_the_tags_of_an_output_profile() {
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
}
}
/// A profile whose localized-name records all point at one long string is refused before moxcms
/// copies each of them; a few short names pass.
#[test]
fn aliased_localized_names_are_refused() {
let profile = |records: u32, length: u32| {
let mut b = vec![0u8; 128];
b.extend(1u32.to_be_bytes());
let tag_at = 128 + 4 + 12;
let size = 16 + 12 * records + length;
b.extend(b"desc");
b.extend((tag_at as u32).to_be_bytes());
b.extend(size.to_be_bytes());
b.extend(b"mluc");
b.extend([0; 4]);
b.extend(records.to_be_bytes());
b.extend(12u32.to_be_bytes());
for _ in 0..records {
b.extend(b"enUS");
b.extend(length.to_be_bytes());
b.extend((16 + 12 * records).to_be_bytes());
}
b.extend(vec![0u8; length as usize]);
b
};
assert!(super::icc::localized_text_fits(&profile(3, 64)));
assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10)));
}
+3 -2
View File
@@ -162,8 +162,9 @@ impl Freeform {
/// ([`Color::lerp_keep_model`]).
pub fn recolor(&mut self, g: &Gradient) {
let n = self.points.len();
for (k, p) in self.points.iter_mut().enumerate() {
(p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model);
let at = (0..n).map(|k| k as f32 / (n - 1).max(1) as f32);
for (p, (color, opacity)) in self.points.iter_mut().zip(g.sample_increasing_with(at, Color::lerp_keep_model)) {
(p.color, p.opacity) = (color, opacity);
}
}
+64 -7
View File
@@ -133,6 +133,16 @@ impl GradientStop {
}
}
/// The colour and opacity at `t` between stops `a` and `b`, through `a`'s midpoint (`u` = midpoint
/// maps to `v` = ½), mixed by `mix`.
fn between(a: &GradientStop, b: &GradientStop, t: f32, mix: &impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) {
let span = (b.offset - a.offset).max(1e-6);
let u = (t - a.offset) / span;
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
(mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v)
}
fn one() -> f32 {
1.0
}
@@ -191,12 +201,7 @@ impl Gradient {
for w in stops.windows(2) {
let (a, b) = (&w[0], &w[1]);
if t <= b.offset {
let span = (b.offset - a.offset).max(1e-6);
let u = (t - a.offset) / span;
// Map through the midpoint: u=mid → 0.5.
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v);
return between(a, b, t, &mix);
}
}
match stops.last() {
@@ -204,6 +209,33 @@ impl Gradient {
None => (Color::BLACK, 1.0),
}
}
/// [`Self::sample_with`] at each of `ts`, which must not decrease. Each search starts where
/// the one before it stopped, so the work is the samples plus the stops, not their product.
pub fn sample_increasing_with(&self, ts: impl IntoIterator<Item = f32>, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> {
let stops = &self.stops;
// The shared index assumes ordered stops; others (a file's, unsorted) sample one by one.
if stops.windows(2).any(|w| w[1].offset < w[0].offset) {
return ts.into_iter().map(|t| self.sample_with(t, &mix)).collect();
}
let mut j = 0;
ts.into_iter()
.map(|t| {
let Some(first) = stops.first() else { return (Color::BLACK, 1.0) };
if t <= first.offset {
return (first.color, first.opacity);
}
// The first pair whose end `t` doesn't pass, as `sample_with` finds it: one that a
// smaller `t` passed, this one passes too.
while j + 1 < stops.len() && t > stops[j + 1].offset {
j += 1;
}
match (stops.get(j), stops.get(j + 1)) {
(Some(a), Some(b)) => between(a, b, t, &mix),
_ => stops.last().map_or((Color::BLACK, 1.0), |l| (l.color, l.opacity)),
}
})
.collect()
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without
/// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that
/// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it).
@@ -213,13 +245,18 @@ impl Gradient {
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
/// on the gradient's own stops and a Perceptual gradient's other in-between stops).
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
// Direct interpolation matches `sample` only for strictly increasing offsets.
let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset);
self.stops.iter().enumerate().flat_map(move |(i, s)| {
let next = self.stops.get(i + 1);
let off_centre = (s.midpoint - 0.5).abs() > 1e-3;
// Classic: a stop at the midpoint, when it is off-centre.
let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = self.sample(t);
// Mixed from the two stops it lies between, as `sample` would: searching all the
// stops again for each midpoint made many stops cost their square.
let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v);
let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) };
(t, c, o, Some(s.midpoint))
});
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
@@ -776,6 +813,26 @@ mod tests {
assert!((mid - 0.5).abs() < 1e-5);
}
/// Sampling increasing positions in one pass gives what sampling each alone does, out-of-order
/// and repeated stop offsets included.
#[test]
fn increasing_samples_match_single_samples() {
let stop = |offset: f32, hex: &str, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let g = Gradient::new(
GradientKind::Linear,
vec![
stop(0.1, "#ff0000", 0.3),
stop(0.4, "#00ff00", 0.7),
stop(0.4, "#0000ff", 0.5),
stop(0.2, "#ffff00", 0.5),
stop(0.9, "#000000", 0.5),
],
);
let ts: Vec<f32> = (0..=40).map(|k| k as f32 / 40.0).collect();
let one_by_one: Vec<_> = ts.iter().map(|&t| g.sample_with(t, Color::lerp_keep_model)).collect();
assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one);
}
#[test]
fn midpoint_shifts() {
let mut g = Gradient::default();
+11
View File
@@ -515,13 +515,24 @@ fn read_acb(bytes: &[u8], fallback: &str) -> Result<SwatchLibrary, String> {
let spot = data.0.windows(8).last() != Some(b"spflproc".as_slice());
let mut lib = SwatchLibrary { name: if title.is_empty() { fallback.into() } else { title }, ..Default::default() };
let mut names = UniqueNames::default();
// Every name gets the prefix and the suffix, and is kept twice (the swatch's and the set of
// names taken): a megabyte book of long affixes and many colors came to hundreds of MB.
let mut kept = 0usize;
for (name, color) in colors.into_iter().filter(|(n, _)| !n.trim().is_empty()) {
kept = kept.saturating_add(2 * (prefix.len() + name.len() + suffix.len()));
if kept > ACB_MAX_NAME_BYTES {
return Err(format!("the color book's names take more than {} MB", ACB_MAX_NAME_BYTES >> 20));
}
let name = names.unique(format!("{prefix}{name}{suffix}").trim().to_string());
lib.swatches.push(Swatch { name, paint: Paint::solid(color), global: spot, spot });
}
Ok(lib)
}
/// Most bytes a color book's full color names (with its prefix and suffix) may take, counted
/// twice as they are kept twice.
const ACB_MAX_NAME_BYTES: usize = 64 << 20;
/// The swatch of an `.ase` color block, `None` for a model other than RGB, CMYK, Lab and Gray.
/// Components are clamped to their ranges.
fn ase_swatch(body: &mut AseData) -> Result<Option<Swatch>, String> {
+6 -6
View File
@@ -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 }
}
+23 -9
View File
@@ -40,11 +40,18 @@ pub type Drawn = Vec<(Vec<Arc<Clip>>, Node)>;
/// The nodes of `items`, those under clips grouped into clipping groups and those in groups into
/// groups (ids from `doc`), in order.
pub fn nest(doc: &mut Document, items: Drawn) -> Vec<Arc<Node>> {
nest_at(doc, items, 0)
let mut left = MAX_CLIP_ELEMENTS;
nest_at(doc, items, 0, &mut left)
}
/// [`nest`] for the clips at `depth` and deeper.
fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
/// Most path elements the clipping paths [`nest`] makes may have together. Each run of objects
/// under a clip gets its own copy of the clip's path, so one large clip used again around many
/// short runs (an EMF restoring one saved clip between drawings) multiplied it. Past it, runs are
/// grouped without their clip.
pub const MAX_CLIP_ELEMENTS: usize = 8_000_000;
/// [`nest`] for the clips at `depth` and deeper, with `left` of [`MAX_CLIP_ELEMENTS`].
fn nest_at(doc: &mut Document, items: Drawn, depth: usize, left: &mut usize) -> Vec<Arc<Node>> {
let mut out: Vec<Arc<Node>> = vec![];
let mut group: Drawn = vec![];
let mut clip: Option<Arc<Clip>> = None;
@@ -52,7 +59,7 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
let here = chain.get(depth).cloned();
if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) {
if let Some(c) = clip.take() {
out.push(clip_group(doc, &c, std::mem::take(&mut group), depth));
out.push(clip_group(doc, &c, std::mem::take(&mut group), depth, left));
}
clip = here.clone();
}
@@ -62,16 +69,23 @@ fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
}
}
if let Some(c) = clip {
out.push(clip_group(doc, &c, group, depth));
out.push(clip_group(doc, &c, group, depth, left));
}
out
}
/// A clipping group of `items`, clipped by `c` (their clip at `depth`), or their group when `c` is
/// a plain group.
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<Node> {
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize, left: &mut usize) -> Arc<Node> {
let mut children = vec![];
if let Some((bp, r)) = &c.region {
let region = c.region.as_ref().filter(|(bp, _)| match left.checked_sub(bp.elements().len()) {
Some(rest) => {
*left = rest;
true
}
None => false,
});
if let Some((bp, r)) = region {
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(bp), Appearance::default());
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
*clipping = true;
@@ -79,6 +93,6 @@ fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<N
}
children.push(Arc::new(path));
}
children.extend(nest_at(doc, items, depth + 1));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: c.region.is_some() }))
children.extend(nest_at(doc, items, depth + 1, left));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: region.is_some() }))
}
+6
View File
@@ -25,6 +25,12 @@ use crate::{Document, Node, NodeKind};
/// can be made can be saved and opened again.
pub const MAX_DEPTH: usize = 32;
/// Most objects the symbol instances drawn in one frame or written in one export may hold
/// together. Instances of symbols that hold instances of others draw every level for each instance
/// above it, so a few levels of a few instances each multiply past what [`MAX_DEPTH`] stops; past
/// it, further instances aren't drawn.
pub const MAX_SYMBOL_NODES: u64 = 16_000_000;
/// The deepest an object may be drawn: as [`MAX_DEPTH`], the art of a symbol counted under its
/// instance and a pattern's tile under the object it paints (and what those use, under them).
/// Symbols a few objects deep nest in one another dozens deep within it.
+1 -1
View File
@@ -127,7 +127,7 @@ impl crate::Document {
break;
}
let Some(name) = GraphDesign::clean_name(&d.name) else { continue };
if kept.iter().any(|k| k.name == name) || d.art.count() > GraphDesign::MAX_NODES {
if kept.iter().any(|k| k.name == name) || d.art.count_with_masks() > GraphDesign::MAX_NODES {
continue;
}
kept.push(GraphDesign { name, art: std::sync::Arc::new(GraphDesign::plain_art(&d.art)) });
+50 -2
View File
@@ -93,7 +93,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use depth::MAX_DEPTH;
pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES};
pub use graph::{
ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition,
PieSort, SeriesPaint, TickLength, ValueAxisSide,
@@ -959,6 +959,12 @@ pub struct Document {
/// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved.
#[serde(skip)]
pub new_art: NewArtIds,
/// The highest artboard id given out ([`Document::next_artboard_id`]) or deleted from the
/// document ([`Document::retire_artboard_id`]): a new artboard takes a higher one. Shared by
/// every copy of the document, the undo history's included, so an undo does not lower it.
/// Never saved.
#[serde(skip)]
pub retired_artboard_id: RetiredArtboardId,
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
/// is set, so documents without any pay nothing for them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -968,6 +974,27 @@ pub struct Document {
index: id_index::IdIndex,
}
/// [`Document::retired_artboard_id`]: not document content, so two documents are equal whatever it
/// holds. A clone shares the value with the document it was cloned from.
#[derive(Clone, Debug, Default)]
pub struct RetiredArtboardId(Arc<std::sync::atomic::AtomicU32>);
impl RetiredArtboardId {
pub fn get(&self) -> u32 {
self.0.load(std::sync::atomic::Ordering::Relaxed)
}
/// Raise the value to `id` when `id` is higher.
pub fn raise(&self, id: u32) {
self.0.fetch_max(id, std::sync::atomic::Ordering::Relaxed);
}
}
impl PartialEq for RetiredArtboardId {
fn eq(&self, _: &Self) -> bool {
true
}
}
fn ppi72() -> f64 {
72.0
}
@@ -1052,6 +1079,7 @@ impl Document {
puppet,
new_art,
dimensions,
retired_artboard_id: _,
index: _,
} = self;
*version == other.version
@@ -1225,6 +1253,7 @@ impl Document {
puppet: None,
new_art: Default::default(),
dimensions: false,
retired_artboard_id: Default::default(),
index: Default::default(),
};
let id = d.alloc_id();
@@ -1940,8 +1969,18 @@ impl Document {
self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect();
self.guides.extend(copies);
}
/// An id for a new artboard: one no artboard has, above the ids given out before and the ids of
/// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts
/// as given out, so an artboard made after an undo does not take the id of one the undo took
/// away.
pub fn next_artboard_id(&self) -> u32 {
free_id(self.artboards.iter().map(|a| a.id))
let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()]));
self.retired_artboard_id.raise(id);
id
}
/// Record that the artboard with `id` was deleted: no later artboard takes its id.
pub fn retire_artboard_id(&self, id: u32) {
self.retired_artboard_id.raise(id);
}
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
self.patterns.iter().find(|p| p.name == name)
@@ -1964,6 +2003,15 @@ impl Document {
{
dimension.source = None;
}
// A copy of a threaded frame is in no thread, so its first paragraph starts in it and its
// layout caches are dropped. A caller that keeps the copy as an object fills them again.
if let NodeKind::Text(t) = &mut n.kind
&& t.first_para_continues
{
t.first_para_continues = false;
t.cached_bounds = None;
t.cached_baselines.clear();
}
let mut renamed = std::collections::HashMap::new();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
+19 -4
View File
@@ -86,13 +86,25 @@ pub fn hash_bytes(bytes: &[u8]) -> String {
}
/// [`hash_bytes`] of everything `r` reads, read 64 KB at a time.
pub fn hash_reader(mut r: impl std::io::Read) -> std::io::Result<String> {
pub fn hash_reader(r: impl std::io::Read) -> std::io::Result<String> {
hash_reader_within(r, u64::MAX).map(|(hash, _)| hash)
}
/// [`hash_reader`] of at most `max` bytes, and how many it read: `max` + 1 when there were more (the
/// hash then covers only the first `max` + 1). A file that grew since its size was looked at isn't
/// read to its new end.
pub fn hash_reader_within(r: impl std::io::Read, max: u64) -> std::io::Result<(String, u64)> {
use std::io::Read;
let mut r = r.take(max.saturating_add(1));
let mut buf = vec![0; 1 << 16];
let mut h = FNV_BASIS;
let (mut h, mut read) = (FNV_BASIS, 0u64);
loop {
match r.read(&mut buf) {
Ok(0) => return Ok(format!("img{h:016x}")),
Ok(n) => h = fnv1a(h, buf.get(..n).unwrap_or_default()),
Ok(0) => return Ok((format!("img{h:016x}"), read)),
Ok(n) => {
h = fnv1a(h, buf.get(..n).unwrap_or_default());
read = read.saturating_add(n as u64);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
@@ -405,6 +417,9 @@ mod tests {
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i * 7 % 251) as u8).collect();
assert_eq!(hash_reader(&bytes[..]).unwrap(), hash_bytes(&bytes));
assert_eq!((hash_reader(&b""[..]).unwrap(), hash_bytes(b"")), ("imgcbf29ce484222325".to_string(), "imgcbf29ce484222325".to_string()));
// Within a size: the whole file at that size; one byte past it when it is longer.
assert_eq!(hash_reader_within(&bytes[..], bytes.len() as u64).unwrap(), (hash_bytes(&bytes), bytes.len() as u64));
assert_eq!(hash_reader_within(&bytes[..], 3).unwrap().1, 4);
}
#[test]
+66 -2
View File
@@ -1614,6 +1614,14 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
/// edges (at most half a point) so neighbours overlap and no seams show.
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
// pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image
// is left out.
if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) {
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![], clip: false };
return g;
}
let (pw, ph) = (im.width as f64, im.height as f64);
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
let mut pieces = Vec::with_capacity(2 * k * k);
@@ -1791,6 +1799,7 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]).
pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
let _generating = Generating::enter();
match &n.kind {
NodeKind::Blend { children, spec } => blend_expand(children, spec),
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks),
@@ -1809,6 +1818,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node {
/// [`expanded_group`] with [`Hooks`].
pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node {
let _generating = Generating::enter();
let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect();
let mut g = Node::new(n.id, NodeKind::Group { children, clip: false });
g.name = n.name.clone();
@@ -1832,9 +1842,21 @@ pub fn expand_deep(n: &Node, outline: Outliner) -> Node {
expand_deep_hooks(n, outline.into())
}
/// [`expand_deep`] with [`Hooks`].
/// [`expand_deep`] with [`Hooks`]. The objects live art makes here count against one
/// [`MAX_GENERATED`] ([`Generating`]): blends of blends kept their inner blends live, and each
/// copy expanded on its own, so step counts capped one by one multiplied. Past it, live art
/// expands to nothing.
pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node {
let mut out = if is_live(n) { expanded_group_hooks(n, hooks) } else { n.clone() };
let _generating = Generating::enter();
let mut out = if is_live(n) {
let g = expanded_group_hooks(n, hooks);
if !Generating::charge(g.count()) {
return Node::new(n.id, NodeKind::Group { children: vec![], clip: false });
}
g
} else {
n.clone()
};
if let Some(ch) = out.children_mut() {
for c in ch.iter_mut() {
if subtree_has_live(c) {
@@ -1845,6 +1867,48 @@ pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node {
out
}
/// Most objects one evaluation of live art (an expansion, an envelope's mapping and everything
/// inside them) may make.
pub const MAX_GENERATED: usize = 4_000_000;
std::thread_local! {
/// What is left of [`MAX_GENERATED`] for the evaluation running on this thread; `None` outside
/// one.
static GENERATED_LEFT: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
/// The outermost live evaluation on this thread: it starts [`GENERATED_LEFT`] and ends it.
struct Generating(bool);
impl Generating {
fn enter() -> Self {
let outermost = GENERATED_LEFT.get().is_none();
if outermost {
GENERATED_LEFT.set(Some(MAX_GENERATED));
}
Self(outermost)
}
/// Spend `n` objects; `false` (and nothing left) when there aren't that many.
fn charge(n: usize) -> bool {
match GENERATED_LEFT.get() {
None => true,
Some(left) => {
GENERATED_LEFT.set(Some(left.saturating_sub(n)));
n <= left
}
}
}
}
impl Drop for Generating {
fn drop(&mut self) {
if self.0 {
GENERATED_LEFT.set(None);
}
}
}
/// Does `n` or any descendant need live evaluation?
pub fn subtree_has_live(n: &Node) -> bool {
let mut any = false;
+8 -2
View File
@@ -105,7 +105,13 @@ fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i6
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
/// the clock decides — an export is never refused over the environment.
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
let value = get("SOURCE_DATE_EPOCH")?;
let value = value.trim();
// `i64::from_str` also takes a leading `+`, which `date +%s` never prints.
if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
value.parse::<i64>().ok()
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
@@ -193,7 +199,7 @@ mod tests {
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "+1791208980", "+0", "99999999999999999999"] {
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
}
assert_eq!(stamp_from_env(env(None)), None, "unset");
+12
View File
@@ -1452,6 +1452,18 @@ impl Node {
self.walk(&mut |_| n += 1);
n
}
/// [`Self::count`], with the art of the opacity masks in the subtree (and theirs): what copying
/// and transforming the subtree makes, as [`Self::walk`] doesn't go into masks.
pub fn count_with_masks(&self) -> usize {
let mut n = 0;
self.walk(&mut |c| {
n += 1;
if let Some(m) = &c.extras().mask {
n += m.art.count_with_masks();
}
});
n
}
/// Fix this node's unplaced gradients to their fit on its current bounds (before a transform
/// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed.
pub fn pin_gradients(&mut self) {
+13 -2
View File
@@ -525,6 +525,9 @@ fn rename(n: &mut Node, prefix: &str) {
});
}
/// Most placed documents [`Document::with_placed_art`] reads for one export, nested ones included.
pub const MAX_PLACED_READS: usize = 256;
impl Document {
/// This document ready to export its placed documents: their files read (the file read again
/// when only the preview is stored, else the preview), and the resources their art uses added
@@ -544,9 +547,17 @@ impl Document {
}
let mut d = self.clone();
let mut found: Vec<Arc<ReadDocument>> = vec![];
// Placed documents are told apart by the names their art's resources get here, so one
// file placed in itself, or many times in a file placed many times, read again in every
// branch: past [`MAX_PLACED_READS`] reads, the rest show their previews.
let mut reads = 0usize;
while let Some((p, depth)) = queue.pop() {
let Some(blob) = d.images.get(&p.key).cloned() else { continue };
let full = if depth < MAX_DEPTH { full_bytes(&p, &blob) } else { None };
// Top-level placements are distinct already (by key): only reads nested inside placed
// documents count, so a catalogue placing many files still reads each of them.
let read_full = depth < MAX_DEPTH && (depth == 0 || reads < MAX_PLACED_READS);
reads += usize::from(read_full && depth > 0);
let full = if read_full { full_bytes(&p, &blob) } else { None };
if full.and_then(|b| read(&p, &b)).is_none() {
let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() };
if let Some(bytes) = preview {
@@ -562,7 +573,7 @@ impl Document {
.chain(e.doc.symbols.iter().map(|s| &s.art))
.chain(e.doc.graph_designs.iter().map(|g| &g.art))
.chain(e.doc.patterns.iter().flat_map(|p| &p.art));
for root in inner {
for root in inner.filter(|_| reads < MAX_PLACED_READS) {
walk_all(root, &mut |n| {
if let NodeKind::PlacedDocument(q) = &n.kind {
let key = format!("{prefix}{}", q.key);
+189 -8
View File
@@ -508,9 +508,102 @@ impl Bullet {
}
}
/// The list a paragraph is in (Paragraph panel › Bullets). The layout draws the list's marker
/// before the paragraph's first line, in the font, size and color of the paragraph's first
/// character without its baseline shift, rotation, superscript or subscript, tracking, manual
/// How a numbered list writes its numbers ([`ParaList::number`]): the Paragraph panel's Numbered
/// List presets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NumberStyle {
/// 1, 2, 3…
#[default]
Arabic,
/// A, B, C… Z, AA, BB…
UpperLetters,
/// a, b, c… z, aa, bb…
LowerLetters,
/// I, II, III, IV…
UpperRoman,
/// i, ii, iii, iv…
LowerRoman,
/// 01, 02… 99, 100…
TwoDigits,
/// 001, 002… 999, 1000…
ThreeDigits,
}
/// Past this number, roman numerals and letters are written in Arabic digits.
const MAX_LETTERS_OR_ROMAN: u32 = 3999;
impl NumberStyle {
/// Every style, in the order the Numbered List presets list them.
pub const ALL: [NumberStyle; 7] = [
NumberStyle::Arabic,
NumberStyle::UpperLetters,
NumberStyle::LowerLetters,
NumberStyle::UpperRoman,
NumberStyle::LowerRoman,
NumberStyle::TwoDigits,
NumberStyle::ThreeDigits,
];
/// Number `n` (1 or more) written in this style, without a separator. Letters run A to Z, then
/// AA to ZZ, then AAA and so on; roman numerals use the subtractive forms (IV, IX, XL, XC, CD,
/// CM); both are written in Arabic digits past 3999. Two and three digits pad with zeros and
/// keep every digit of a longer number.
pub fn format(self, n: u32) -> String {
let letters_or_roman =
matches!(self, NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::UpperRoman | NumberStyle::LowerRoman);
if n == 0 || (letters_or_roman && n > MAX_LETTERS_OR_ROMAN) {
return n.to_string();
}
match self {
NumberStyle::Arabic => n.to_string(),
NumberStyle::TwoDigits => format!("{n:02}"),
NumberStyle::ThreeDigits => format!("{n:03}"),
NumberStyle::UpperLetters => letters(n),
NumberStyle::LowerLetters => letters(n).to_lowercase(),
NumberStyle::UpperRoman => roman(n),
NumberStyle::LowerRoman => roman(n).to_lowercase(),
}
}
}
/// `n` (1 or more) in capital letters: A to Z, then each letter twice, three times and so on.
fn letters(n: u32) -> String {
let n = n - 1;
let letter = char::from(b'A' + (n % 26) as u8);
std::iter::repeat_n(letter, (n / 26 + 1) as usize).collect()
}
/// `n` (1 to 3999) in capital roman numerals.
fn roman(mut n: u32) -> String {
const DIGITS: [(u32, &str); 13] = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
let mut s = String::new();
for (value, digits) in DIGITS {
while n >= value {
s.push_str(digits);
n -= value;
}
}
s
}
/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the
/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's
/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
@@ -518,19 +611,52 @@ impl Bullet {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct ParaList {
/// The bullet each paragraph of the list starts with.
/// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for
/// when the list is made bulleted again.
pub bullet: Bullet,
/// A numbered list's number style. A bulleted list has none, and the file saves it only for a
/// numbered list.
#[serde(skip_serializing_if = "Option::is_none")]
pub number: Option<NumberStyle>,
}
impl ParaList {
/// A bulleted list with `bullet`.
pub fn bulleted(bullet: Bullet) -> Self {
Self { bullet }
Self { bullet, number: None }
}
/// The characters of the marker the layout draws before the paragraph.
pub fn marker(&self) -> String {
self.bullet.character().to_string()
/// A numbered list with `style`.
pub fn numbered(style: NumberStyle) -> Self {
Self { bullet: Bullet::default(), number: Some(style) }
}
/// The characters of the marker the layout draws before the paragraph: the bullet, or for a
/// numbered list number `n` in its style followed by a period.
pub fn marker(&self, n: u32) -> String {
match self.number {
Some(style) => format!("{}.", style.format(n)),
None => self.bullet.character().to_string(),
}
}
/// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and
/// which holds no text when `empty`, with `count` the numbered paragraphs counted before it.
/// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has
/// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the
/// count as it is; a numbered paragraph adds one to the count and is numbered with it.
pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> {
let Some(list) = list else {
*count = 0;
return None;
};
if empty {
return None;
}
if list.number.is_some() {
*count = count.saturating_add(1);
}
Some(list.marker(*count))
}
/// The style the marker is set in: `first` (the style of the paragraph's first character)
@@ -1501,6 +1627,12 @@ pub struct TextObject {
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paras: Vec<ParaStyle>,
/// Threaded text: the first paragraph continues from the frame before this one in its thread,
/// so its first line here has no first-line indent and no list marker. The engine sets it for
/// every frame of a thread when the story flows through them and when a file opens, and clears
/// it on a frame that leaves its thread and on a copy. Not saved.
#[serde(skip)]
pub first_para_continues: bool,
/// Area Type Options (area type only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub area: AreaOptions,
@@ -1536,6 +1668,12 @@ pub struct TextObject {
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
#[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")]
pub fractional_widths: bool,
/// Threaded text: the numbered list paragraphs the story's earlier frames count before this
/// frame's first paragraph ([`ParaList::next_marker`]), so one count runs through the story.
/// The engine sets it whenever it re-flows a thread. Type that is not threaded has 0, and the
/// file saves the count only when it is not 0.
#[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")]
pub list_count: u32,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
@@ -1627,6 +1765,7 @@ impl TextObject {
ruby: Vec::new(),
para: ParaStyle::default(),
paras: Vec::new(),
first_para_continues: false,
area: AreaOptions::default(),
path_effect: PathEffect::default(),
path_align: PathAlign::default(),
@@ -1635,6 +1774,7 @@ impl TextObject {
optical_margin: false,
system_layout: false,
fractional_widths: true,
list_count: 0,
cached_bounds: None,
cached_baselines: Vec::new(),
}
@@ -2132,6 +2272,47 @@ mod tests {
assert_eq!(same, d);
}
/// Numbered lists: each number style writes its numbers and rolls over (letters after Z,
/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds
/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and
/// starts again after a paragraph in no list. A list from before number styles reads as
/// bulleted, and a bulleted list saves no number style.
#[test]
fn number_styles_write_their_numbers_and_roll_over() {
let numbers = |style: NumberStyle, ns: &[u32]| ns.iter().map(|&n| style.format(n)).collect::<Vec<_>>();
assert_eq!(numbers(NumberStyle::Arabic, &[1, 9, 10, 123]), ["1", "9", "10", "123"]);
assert_eq!(
numbers(NumberStyle::UpperLetters, &[1, 2, 26, 27, 28, 52, 53, 3999, 4000]),
["A", "B", "Z", "AA", "BB", "ZZ", "AAA", &"U".repeat(154), "4000"]
);
assert_eq!(numbers(NumberStyle::LowerLetters, &[1, 26, 27]), ["a", "z", "aa"]);
assert_eq!(
numbers(NumberStyle::UpperRoman, &[1, 2, 3, 4, 5, 9, 14, 40, 90, 400, 1994, 3999, 4000]),
["I", "II", "III", "IV", "V", "IX", "XIV", "XL", "XC", "CD", "MCMXCIV", "MMMCMXCIX", "4000"]
);
assert_eq!(numbers(NumberStyle::LowerRoman, &[1, 4, 9, 12]), ["i", "iv", "ix", "xii"]);
assert_eq!(numbers(NumberStyle::TwoDigits, &[1, 9, 10, 99, 100]), ["01", "09", "10", "99", "100"]);
assert_eq!(numbers(NumberStyle::ThreeDigits, &[1, 10, 999, 1000]), ["001", "010", "999", "1000"]);
assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV.");
assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}");
let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot)));
let mut count = 0;
let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)]
.iter()
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count))
.collect();
assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]);
assert_eq!(count, 1);
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash));
assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits));
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
assert_eq!(
serde_json::to_value(ParaList::numbered(NumberStyle::LowerRoman)).unwrap(),
serde_json::json!({"bullet": "dot", "number": "lowerRoman"})
);
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
+27 -3
View File
@@ -32,9 +32,11 @@ pub fn to_object_name(id: &str) -> String {
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
let last = name.chars().count().saturating_sub(1);
for (i, c) in name.chars().enumerate() {
if c == ' ' && i == last {
// A trailing bare `_` could read back as the end of a uniqueness suffix.
let chars: Vec<char> = name.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == ' ' && (i == last || starts_fake_escape(chars.get(i + 1..).unwrap_or_default())) {
// A trailing bare `_` could read back as the end of a uniqueness suffix, and a bare
// `_` before `x<hex>_` would read back as an `_xHH_` escape.
out.push_str("_x20_");
} else if c == ' ' {
out.push('_');
@@ -68,6 +70,19 @@ pub fn is_valid(id: &str) -> bool {
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
/// Whether the encoded `rest` (after a space) starts with `x`, 1 to 6 hex digits and a character
/// whose encoding begins with `_`, so that a bare `_` before it would form an escape.
fn starts_fake_escape(rest: &[char]) -> bool {
let Some(('x', digits)) = rest.split_first() else {
return false;
};
let n = digits.iter().take_while(|c| c.is_ascii_hexdigit()).count();
if n == 0 || n > 6 {
return false;
}
digits.get(n).is_some_and(|&c| c == ' ' || c == '_' || !(c.is_alphanumeric() || matches!(c, '.' | '-' | ':')))
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
@@ -119,6 +134,15 @@ mod tests {
assert_eq!(to_object_name("a_x-41_b"), "a x-41 b");
}
#[test]
fn space_before_x_hex_and_underscore_is_not_an_escape() {
for name in ["layer x1 copy", "a xa b", "a xe b", "a xb b", "a x1(b", "a x1_b", "a x123456 c"] {
let id = from_object_name(name);
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
}
assert_eq!(from_object_name("layer x1 copy"), "layer_x20_x1_copy");
}
#[test]
fn round_trip_names_that_cannot_start_an_xml_name() {
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
+46 -13
View File
@@ -7,6 +7,7 @@
//! directions.
use std::io::Cursor;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use vectorcraft_geom::Point;
@@ -26,6 +27,15 @@ const RELIEF: f64 = 1.0;
/// How many decoded textures [`texture`] keeps.
const KEEP: usize = 4;
/// Most bytes of gray levels the loaded textures alive at once may hold, wherever they are kept
/// (the effects of every drawing hold theirs): a texture that would pass it isn't decoded. Each
/// one is decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`], but a document's many Glass
/// effects each kept their own.
const MAX_LIVE: usize = 512 << 20;
/// Bytes of gray levels the [`LoadedTexture`]s alive hold ([`MAX_LIVE`]).
static LIVE: AtomicUsize = AtomicUsize::new(0);
/// A loaded texture: `width` × `height` gray levels, row by row from the top.
#[derive(Debug, PartialEq, Eq)]
pub struct LoadedTexture {
@@ -34,7 +44,19 @@ pub struct LoadedTexture {
gray: Vec<u8>,
}
impl Drop for LoadedTexture {
fn drop(&mut self) {
LIVE.fetch_sub(self.gray.len(), Ordering::Relaxed);
}
}
impl LoadedTexture {
/// A texture of `gray`, counted in [`LIVE`].
fn new(width: u32, height: u32, gray: Vec<u8>) -> Self {
LIVE.fetch_add(gray.len(), Ordering::Relaxed);
Self { width, height, gray }
}
/// The texture of `width` × `height` straight (not premultiplied) RGBA pixels: each pixel's
/// luma over middle gray, as much as its alpha covers it (a transparent pixel is middle gray).
/// `None` when the image is empty or `rgba` doesn't hold exactly that many pixels.
@@ -52,7 +74,7 @@ impl LoadedTexture {
((a * l + (1.0 - a) * 0.5) * 255.0).round().clamp(0.0, 255.0) as u8
})
.collect();
Some(Self { width, height, gray })
Some(Self::new(width, height, gray))
}
/// The size in pixels.
@@ -71,14 +93,23 @@ impl LoadedTexture {
/// The texture of a PNG's gray levels, decoded within [`vectorcraft_doc::MAX_RASTER_ALLOC`];
/// `None` when `png` isn't a PNG or holds no pixels.
/// `None` too when its gray levels would take the live textures past [`MAX_LIVE`].
fn from_png(png: &[u8]) -> Option<Self> {
let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
reader.limits(limits);
let img = reader.decode().ok()?.to_luma8();
let reader = || {
let mut reader = image::ImageReader::with_format(Cursor::new(png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_alloc = Some(vectorcraft_doc::MAX_RASTER_ALLOC);
reader.limits(limits);
reader
};
let (w, h) = reader().into_dimensions().ok()?;
let gray = (w as usize).checked_mul(h as usize)?;
if LIVE.load(Ordering::Relaxed).saturating_add(gray) > MAX_LIVE {
return None;
}
let img = reader().decode().ok()?.to_luma8();
let (width, height) = img.dimensions();
(width > 0 && height > 0).then(|| Self { width, height, gray: img.into_raw() })
(width > 0 && height > 0).then(|| Self::new(width, height, img.into_raw()))
}
/// The surface's height (points) at `p` (points at 100 %, from the object's center): the gray
@@ -99,24 +130,26 @@ impl LoadedTexture {
}
}
/// The textures [`texture`] decoded last, with their stored form, the newest last.
static DECODED: Mutex<Vec<(String, Arc<LoadedTexture>)>> = Mutex::new(Vec::new());
/// The textures [`texture`] decoded last, with their PNG, the newest last.
static DECODED: Mutex<Vec<(Vec<u8>, Arc<LoadedTexture>)>> = Mutex::new(Vec::new());
/// The texture a [`TEXTURE_IMAGE`] value holds; `None` when it isn't base64 of a PNG. The last
/// [`KEEP`] textures decoded are kept for the next call.
/// [`KEEP`] textures decoded are kept for the next call, found by their PNG: values that differ
/// only in how the base64 is spaced share one.
pub fn texture(stored: &str) -> Option<Arc<LoadedTexture>> {
let png = base64_decode(stored)?;
let mut decoded = DECODED.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(i) = decoded.iter().position(|(s, _)| s == stored) {
if let Some(i) = decoded.iter().position(|(p, _)| *p == png) {
let entry = decoded.remove(i);
let t = entry.1.clone();
decoded.push(entry);
return Some(t);
}
let t = Arc::new(LoadedTexture::from_png(&base64_decode(stored)?)?);
let t = Arc::new(LoadedTexture::from_png(&png)?);
if decoded.len() >= KEEP {
decoded.remove(0);
}
decoded.push((stored.to_string(), t.clone()));
decoded.push((png, t.clone()));
Some(t)
}
+2 -1
View File
@@ -153,7 +153,8 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::Bez
let layout = vectorcraft_text::layout(db, t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
for g in &layout.glyphs {
// Ruby (furigana) glyphs are painted with their base text's run, as the renderer paints them.
for g in layout.glyphs.iter().chain(&layout.ruby) {
if let Some(r) = runs.get_mut(g.run) {
r.extend(g.outline.iter());
}
+22
View File
@@ -12,6 +12,24 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
pub const REVOLVE: &str = "threeD.revolve";
/// Most bytes the copies of the object's paint in the faces may take together. Each face and the
/// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of
/// thousands of faces took gigabytes; past this the source path is drawn.
const MAX_PAINT_COPIES: usize = 256 << 20;
/// About the bytes one copy of `paint` takes.
fn paint_bytes(paint: &Paint) -> usize {
use std::mem::size_of;
use vectorcraft_doc::color::{FreeformPoint, GradientStop};
let own = size_of::<Paint>();
let Paint::Gradient(g) = paint else { return own };
let freeform = g
.freeform
.as_ref()
.map_or(0, |f| f.points.len() * size_of::<FreeformPoint>() + f.lines.iter().map(|l| l.len() * size_of::<usize>()).sum::<usize>());
own + size_of::<vectorcraft_doc::color::GradientPaint>() + g.gradient.stops.len() * size_of::<GradientStop>() + freeform
}
fn visible(fx: &[Effect]) -> bool {
fx.iter().any(|e| e.visible && e.id == REVOLVE)
}
@@ -93,6 +111,10 @@ fn surface(
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
// A face's fill, its shadow and their seam strokes: up to four copies of the paint.
if faces.len().saturating_mul(4).saturating_mul(paint_bytes(&paint)) > MAX_PAINT_COPIES {
return None;
}
// Visibility is an expansion option, never a change to live geometry or export baking.
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
// failure also retains the complete surface rather than losing any of the user's art.
+3 -1
View File
@@ -58,7 +58,9 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>,
};
match seg {
PathSeg::Line(l) => push(l.p1),
_ => kurbo::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| {
// Bounded per curve: a projection can blow a small curve up enough that kurbo sizes
// an enormous buffer from it.
_ => vectorcraft_geom::flatten([PathEl::MoveTo(seg.start()), seg.as_path_el()], tol, |el| {
if let PathEl::LineTo(p) = el {
push(p);
}
+5
View File
@@ -45,6 +45,11 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# Whether a recovery area folder is this account's own (`FolderStore::own_area`). Already in
# Cargo.lock (tempfile, ui-egui), so this adds no new crate.
[target.'cfg(unix)'.dependencies]
rustix = { workspace = true, features = ["std", "process"] }
[dev-dependencies]
vectorcraft-affinity = { workspace = true, features = ["synth"] }
crc32fast = { workspace = true }
+64 -37
View File
@@ -19,8 +19,7 @@ use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sample_polyline,
sorted_roots,
self as b, BuilderMap, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, Touched, edge_near, face_at, is_live_paint, sorted_roots,
};
use vectorcraft_trace as tr;
@@ -58,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Live Paint"],
Some("Cmd+Alt+X"),
"{ids?|id?} build a Live Paint group from the selected paths; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
"{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}",
has_selection_or_ids,
lp_make
),
@@ -67,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge",
["Object", "Live Paint"],
None,
"{ids?|id?} add the other selected paths (and Live Paint groups) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
"{ids?|id?} add the other selected paths (compound paths, groups of paths and Live Paint groups too; type, images and other objects are refused, nothing changes) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}",
has_multi_or_ids,
lp_merge
),
@@ -130,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down)}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -296,7 +295,6 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
}
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
if let Some(tol) = split_tol
&& let Some(&q) = pts.first()
&& let Some((i, run)) = map.stroke_at(&outlines, q, tol)
@@ -329,9 +327,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
return Ok((ids, None, touched, Some(split)));
}
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
for q in sample_polyline(&pts, step) {
touched.add(map.hit(q, piece_tol));
}
touched.extend(&map.hits_along(&pts, piece_tol));
touched.edges = map.erased_edges(&touched);
if touched.is_empty() {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
@@ -358,7 +354,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
if !pieces.is_empty() {
// An open path loses the pieces deleted and those between merged (or deleted) regions.
// An open path loses the pieces deleted and those between two merged (or deleted) regions.
let keep: Vec<usize> =
pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect();
if keep.len() == pieces.len() {
@@ -609,9 +605,24 @@ fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
}
/// Refuse `roots` when one of them is something Live Paint can't take ([`b::live_paint_takes`]):
/// its faces and edges come from path outlines alone, so type or an image would vanish among the
/// group's hidden sources. Checked before anything changes.
fn check_live_paint_takes(d: &Document, roots: &[NodeId]) -> Result<()> {
for r in roots {
if !b::live_paint_takes(d.node(*r).ok_or(EngineError::NoNode(*r))?) {
return Err(EngineError::Other(
"Live Paint takes paths and compound paths: convert type, images and other objects to paths first".into(),
));
}
}
Ok(())
}
/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them
/// as Merge does; returns (group, faces, edges).
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
check_live_paint_takes(d, roots)?;
if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) {
return merge_live(d, sel, roots);
}
@@ -644,6 +655,7 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result
.copied()
.find(|r| d.node(*r).is_some_and(is_live_paint))
.ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?;
check_live_paint_takes(d, roots)?;
let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
// Paint to carry over: (point inside face, fill) and (point on edge, stroke).
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
@@ -951,40 +963,44 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
v.as_str().and_then(TraceView::from_id).map(Some).ok_or_else(|| bad(cmd, format!("view must be one of {}", ids())))
}
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
/// The parameters command `c` traces with: the preset's, with `params` over them.
fn trace_params(c: &str, p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?;
let enhanced = switch_or(p, "enhanced", false, c)?;
// (A preset without an enhanced version is traced as it is.)
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let base = base.ok_or_else(|| bad(c, format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad(c, "params must be an object")) };
// A key that isn't one of the settings is an error, with the nearest setting.
if let Some(key) = src.keys().find(|k| !o.contains_key(k.as_str())) {
return Err(super::params::unknown(c, key, &o.keys().map(String::as_str).collect::<Vec<_>>()));
}
for (k, val) in src {
o.insert(k.clone(), val.clone());
}
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad("imageTrace.make", e.to_string()))?;
let params: tr::TraceParams = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?;
if !params.fills && !params.strokes {
return Err(bad("imageTrace.make", "Create needs fills, strokes or both"));
return Err(bad(c, "Create needs fills, strokes or both"));
}
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
return Err(bad("imageTrace.make", "strokeWidth must be a positive number of pixels"));
return Err(bad(c, "strokeWidth must be a positive number of pixels"));
}
Ok(params)
}
/// Document Library in Color mode: look the library's colours up into `params.swatches` → each
/// one as traced (sRGB) with the colour to fill with.
fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
const C: &str = "imageTrace.make";
/// one as traced (sRGB) with the colour to fill with. Errors name command `c`.
fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Color)>> {
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
return Ok(vec![]);
}
let key = params.library.as_str();
let colors = super::swatchlib::library_colors(s, key)
.ok_or_else(|| bad(C, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
.ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?;
if colors.is_empty() {
return Err(bad(C, format!("`{key}` has no colours")));
return Err(bad(c, format!("`{key}` has no colours")));
}
let library: Vec<([u8; 3], Color)> = colors
.into_iter()
@@ -1088,9 +1104,9 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
}
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let mut params = trace_params(p)?;
let view = view_param(C, p)?;
let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" };
let mut params = trace_params(c, p)?;
let view = view_param(c, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
// else "Custom".
@@ -1114,14 +1130,14 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
_ => None,
}
})
.ok_or_else(|| bad(C, "select an image (or an Image Trace group)"))?
.ok_or_else(|| bad(c, "select an image (or an Image Trace group)"))?
};
let blob = s.doc()?.doc.images.get(&img.key).ok_or_else(|| EngineError::Other(format!("image data `{}` is missing", img.key)))?;
// An image over the tracer's size is traced scaled down (the paths are scaled back below).
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let library = trace_library(s, &mut params)?;
let library = trace_library(c, s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height));
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height), res.worked_at);
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
@@ -1197,18 +1213,21 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
/// Images larger than this on a side are traced, but more slowly: the trace says so.
pub const LARGE_TRACE_SIDE: u32 = 1500;
/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that a
/// large image takes longer (the public documentation's warning for images over 1500 px), and that
/// one over the tracer's [`tr::MAX_PIXELS`] was traced scaled down.
pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec<String> {
/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that
/// Flat Logo averaged it down to `worked` pixels first, or else that a large image takes longer
/// (the public documentation's warning for images over 1500 px) and that one over the tracer's
/// [`tr::MAX_PIXELS`] was traced scaled down.
pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32), worked: Option<(u32, u32)>) -> Vec<String> {
let mut out = vec![];
if size.0.max(size.1) > LARGE_TRACE_SIDE {
if worked.is_none() && size.0.max(size.1) > LARGE_TRACE_SIDE {
out.push(format!(
"The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.",
size.0, size.1
));
}
if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) {
if let Some(worked) = worked {
out.push(format!("Flat Logo traced the image at {} × {} pixels, averaged down from {} × {}.", worked.0, worked.1, size.0, size.1));
} else if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) {
out.push(format!(
"The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).",
traced.0,
@@ -1294,11 +1313,19 @@ mod trace_limit_tests {
#[test]
fn trace_warnings_name_a_large_or_scaled_down_image() {
assert!(trace_warnings((1500, 1500), (1500, 1500)).is_empty());
assert_eq!(trace_warnings((1501, 10), (1501, 10)).len(), 1);
let w = trace_warnings((16000, 12000), (9459, 7094));
assert!(trace_warnings((1500, 1500), (1500, 1500), None).is_empty());
assert_eq!(trace_warnings((1501, 10), (1501, 10), None).len(), 1);
let w = trace_warnings((16000, 12000), (9459, 7094), None);
assert_eq!(w.len(), 2, "{w:?}");
assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}");
// Flat Logo's own working size is named instead of the megapixel limit.
let w = trace_warnings((1025, 1025), (1025, 1025), Some((513, 513)));
assert_eq!(w, ["Flat Logo traced the image at 513 × 513 pixels, averaged down from 1025 × 1025."]);
// Flat Logo traces a large image at its reduced size, so it gives no slow-trace warning.
let w = trace_warnings((1600, 1600), (1600, 1600), Some((800, 800)));
assert_eq!(w, ["Flat Logo traced the image at 800 × 800 pixels, averaged down from 1600 × 1600."]);
let w = trace_warnings((16000, 12000), (9459, 7094), Some((946, 710)));
assert_eq!(w, ["Flat Logo traced the image at 946 × 710 pixels, averaged down from 16000 × 12000."]);
}
// A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit;
+2
View File
@@ -206,6 +206,8 @@ pub(crate) fn draw_inside_group(d: &mut vectorcraft_doc::Document, target: NodeI
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![std::sync::Arc::new(mask)], clip: true }))?;
d.move_node(target, Some(gid), usize::MAX)?;
// Copied type whose layout caches `reid` dropped has them again.
super::typecmd::refresh_bounds_of(d, &[gid]);
Ok(gid)
}
+5 -2
View File
@@ -436,8 +436,11 @@ fn snap_xf(b: Rect, half: bool) -> Affine {
}
fn pixel_perfect(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> =
if p.get("ids").is_some() { super::edit::outermost(&s.doc()?.doc, targets(s, p)?) } else { super::edit::selected_roots(s)? };
let ids: Vec<NodeId> = if p.get("ids").is_some() || p.get("id").is_some() {
super::edit::outermost(&s.doc()?.doc, targets(s, p)?)
} else {
super::edit::selected_roots(s)?
};
let n = ids.len();
s.edit("Make Pixel Perfect", |d, _| {
for id in &ids {
+6 -6
View File
@@ -269,11 +269,11 @@ fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
}
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
let n = ids.len();
let from = s.doc()?.selection.objects.clone();
s.select(|_, sel| sel.set(ids))?;
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
Ok(json!({ "count": n }))
let st = s.doc_mut()?;
st.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
Ok(json!({ "count": st.selection.objects.len() }))
}
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
@@ -312,9 +312,9 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool)
fn by_kind(s: &mut Session, f: impl Fn(&Node) -> bool) -> Result<Value> {
let ids = candidates(s, f)?;
let n = ids.len();
s.select(|_, sel| sel.set(ids))?;
Ok(json!({ "count": n }))
// The objects selected, after Lock Guides has left out guide paths.
Ok(json!({ "count": s.doc()?.selection.objects.len() }))
}
fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
@@ -617,7 +617,7 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
if v {
let active = s.active;
for (i, st) in s.docs.iter_mut().enumerate() {
if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) {
if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) {
st.revision += 1;
}
}
+82 -21
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(
"document.setUnits",
@@ -99,8 +99,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Begin Undo Group",
[],
None,
"{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)",
has_doc,
"{label?: string} until edit.undoGroup.end, the edits made in the document active at the begin, and in each document opened or created before then (also when no document is open; the edits of a command that opens a document and edits it stay that command's undo step), are one undo step in that document, named `label` (else after its first edit); in a document that was open before the begin and is switched to (document.activate), each edit stays its own undo step: for automation, such as MCP command_batch with `atomic` (#1163)",
always,
undo_group_begin
),
cmd!(
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
[],
None,
"{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone",
has_doc,
always,
undo_group_end
),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
@@ -243,19 +243,82 @@ fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result<Option<u64>> {
}
fn doc_find(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.find";
// A filter of the wrong type is an error, not dropped: dropping it would widen the search.
let needle = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(v)) => Ok(Some(v.to_lowercase()).filter(|v| !v.is_empty())),
Some(_) => Err(bad("document.find", format!("`{key}` must be a string"))),
Some(_) => Err(bad(C, format!("`{key}` must be a string"))),
};
let filter = inspect::FindFilter { name: needle("name")?, kind: needle("kind")?, text: needle("text")? };
if filter.is_empty() {
return Err(bad("document.find", "give at least one of `name`, `kind`, `text`"));
let rect = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => match finite_numbers::<4>(v) {
Some([x, y, w, h]) if w >= 0.0 && h >= 0.0 => Ok(Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h))),
_ => Err(bad(C, format!("`{key}` must be [x, y, width, height]: four finite numbers, width and height not negative"))),
},
};
let bound = |key: &str| match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0).map(Some).ok_or_else(|| bad(C, format!("`{key}` must be a number not below 0"))),
};
let range = |name: &str| -> Result<inspect::FindRange> {
let (min_key, max_key) = (format!("min{name}"), format!("max{name}"));
let r = inspect::FindRange { min: bound(&min_key)?, max: bound(&max_key)? };
if let (Some(min), Some(max)) = (r.min, r.max)
&& min > max
{
return Err(bad(C, format!("`{min_key}` is greater than `{max_key}`")));
}
Ok(r)
};
let anchors = range("Anchors")?;
if [anchors.min, anchors.max].iter().flatten().any(|n| n.fract() != 0.0) {
return Err(bad(C, "`minAnchors` and `maxAnchors` must be whole numbers"));
}
let limit = opt_u64(p, "document.find", "limit")?.unwrap_or(100);
let (matches, total) = inspect::find_nodes(&s.doc()?.doc, &filter, limit);
Ok(json!({"matches": matches, "total": total}))
let filter = inspect::FindFilter {
name: needle("name")?,
kind: needle("kind")?,
text: needle("text")?,
within: rect("within")?,
intersects: rect("intersects")?,
width: range("Width")?,
height: range("Height")?,
thickness: range("Thickness")?,
area: range("Area")?,
anchors,
};
if filter.is_empty() {
return Err(bad(
C,
"give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`",
));
}
let select = match p.get("select") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or_else(|| bad(C, "`select` must be true or false"))?,
};
let limit = opt_u64(p, C, "limit")?.unwrap_or(100);
let (matches, ids) = inspect::find_nodes(&s.doc()?.doc, &filter, limit);
let mut reply = json!({"matches": matches, "total": ids.len()});
if select {
reply["selected"] = select_found(s, p, &ids)?["count"].clone();
s.journal_query();
}
Ok(reply)
}
/// Select the objects among `ids` that Select All and the objects inside them take (none hidden or
/// locked, nor inside a hidden or locked one), leaving out those inside another of them and the
/// members of compound paths, unless a compound path is isolated. Select > Reselect repeats
/// `document.find` with `p`.
fn select_found(s: &mut Session, p: &Value, ids: &[NodeId]) -> Result<Value> {
let st = s.doc()?;
let compound = |n: &Node| matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. });
let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound)));
let found: BTreeSet<NodeId> = ids.iter().copied().collect();
super::select::select_where(s, "document.find", p, |d, n| {
found.contains(&n.id) && (members || !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
})
}
/// Document Setup's units (also `document.setup {units}`).
@@ -364,6 +427,7 @@ fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()),
Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")),
};
s.explicit_undo_group = Some(label.clone());
s.begin_named_undo_group(label);
ok()
}
@@ -390,7 +454,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
let menu = s.menu.clone();
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
@@ -405,10 +469,8 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
// Guides locked since the step was made stay deselected.
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
// Guides locked and slices locked or hidden since the step was made stay deselected.
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
st.sync_new_art();
super::maskedit::follow_history(st, editing);
leave_isolation_if_gone(st);
@@ -418,7 +480,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
let menu = s.menu.clone();
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
@@ -433,9 +495,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
st.sync_new_art();
super::maskedit::follow_history(st, editing);
leave_isolation_if_gone(st);
@@ -836,7 +896,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
/// document, each original and its copy before the copy goes in, and must leave the document's
/// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside
/// another root, are copied one at a time: each copy is made from the document as the copies
/// before it left it.
/// before it left it. Copied type whose layout caches [`Document::reid`] dropped has them again.
pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result<Vec<NodeId>> {
let paths = d.index_paths(roots.iter().copied());
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
@@ -863,6 +923,7 @@ pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl Fn
}
out
};
super::typecmd::refresh_bounds_of(d, &out);
Ok(out)
}
+5 -1
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
can_expand,
expand_appearance
),
@@ -692,9 +692,13 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let ids = s.edit("Expand Appearance", |d, _| {
let brushes = vectorcraft_brush::library(d);
let mut out = vec![];
let threaded = super::threads::frames(d);
for id in &roots {
expand_node(d, *id, &brushes, &mut out);
}
// Outlined frames of threaded text leave their threads with the text they show; type that
// stays type under a new id takes its frame's place.
super::threads::leave_threads(d, &threaded);
if out.is_empty() {
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
}
+6 -7
View File
@@ -11,8 +11,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
// The active artboard the app passes, for the steps that act on it.
let artboard = p.get("artboard").and_then(Value::as_u64);
// What an error rolls back besides the documents: session-level state a step may change
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
// (paint defaults, drawing mode, clipboard, the Untitled-N count, a begun undo group).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter, s.explicit_undo_group.clone());
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
// would otherwise run inside it and take its baseline as theirs (#1697).
if s.active().is_some() {
@@ -30,7 +30,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
Err(e) => {
roll_back(s, start, &stash);
s.active = active;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved;
s.pending_paint = None;
return Err(e);
}
@@ -99,7 +99,8 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
/// written. Lock Guides, Lock Slices and Hide Slices stay as the steps left them, and the guides
/// and slices they keep from being selected are deselected.
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
let open = std::mem::take(&mut s.docs);
s.docs = start;
@@ -107,8 +108,6 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
// Past the newest revision the document reached, so the restored one redraws.
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
st.revision = newest.saturating_add(1);
if s.menu.guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection);
}
}
+18 -12
View File
@@ -1,6 +1,7 @@
//! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As
//! → EPS Options): the options parsed from the params, transparency flattened with a flattener
//! preset, one file of the art's bounds or one per artboard, the preview and thumbnail rendered,
//! preset, one file of the art's bounds or one per artboard (with a central file of the whole
//! document when there are several), the preview and thumbnail rendered,
//! and the native document embedded for reopening.
use std::borrow::Cow;
@@ -28,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export EPS",
[],
None,
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard; with two or more, also the central file {stem}.eps first: the whole document, the bounding box the visible art) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
has_doc,
export_eps
)]
@@ -43,7 +44,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
name: "useArtboards",
ty: "boolean",
default: "null",
description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps; false or absent: one file of the visible art (naming artboards also uses them)",
description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps, and with two or more a central <file>.eps of the whole document; false or absent: one file of the visible art (naming artboards also uses them)",
},
FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" },
FormatOption {
@@ -197,7 +198,8 @@ fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Re
}
/// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner
/// as the origin, or the visible art's bounds with the first artboard's corner.
/// as the origin, or the visible art's bounds with the first artboard's corner. With two or more
/// chosen artboards, a central file of the visible art comes first (none when no art shows).
fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Point)>> {
let n = doc.artboards.len();
let named = set.boards.resolve(n).map_err(|e| bad(C, e))?;
@@ -208,14 +210,18 @@ fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Poi
(None, None) => None,
};
let corner = |r: Rect| Point::new(r.x0, r.y1);
let art = |art: Rect| (None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)));
match boards {
Some(boards) => boards
.into_iter()
.map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))))
.collect(),
Some(boards) => {
let central = (boards.len() > 1).then(|| vectorcraft_render::encode::art_bounds(doc).map(art)).flatten();
let each = boards
.into_iter()
.map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))));
central.map(Ok).into_iter().chain(each).collect()
}
None => {
let art = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
Ok(vec![(None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)))])
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
Ok(vec![art(bounds)])
}
}
}
@@ -251,8 +257,8 @@ fn native(doc: &Document, board: Option<usize>, include_linked: bool) -> Vec<u8>
}
/// Encode `doc` as EPS: one file of the visible art, or with Use Artboards one per chosen
/// artboard. Transparency is flattened first (once for all of them); the previews show `doc`
/// itself.
/// artboard (after a central file of the whole document when there are several). Transparency is
/// flattened first (once for all of them); the previews show `doc` itself.
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
let set = parse(p)?;
let original = doc;
+9 -2
View File
@@ -323,6 +323,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
crate::cmd::threads::share_optical_margin(&mut doc);
// A frame whose first paragraph continues from the frame before draws it without the
// paragraph's first-line indent and list marker.
crate::cmd::threads::mark_continued(&mut doc);
}
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
@@ -463,8 +466,12 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
st.converted = converted;
st.imported_from = source_path;
st.import_losses = losses;
let title = s.documents()[index].title();
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
let opened = &s.documents()[index];
let (title, file_info_title) = (opened.title(), opened.doc.title.clone());
Ok(super::merge(
json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }),
links.to_json(),
))
}
/// A file named by a command's params: `{path}` (read from disk) or `{name, dataBase64}`.
+4 -3
View File
@@ -74,8 +74,9 @@ pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, save_filters, save_format, save_job, save_plan,
save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, export_folder, reads_option as save_reads_option,
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
templates_folder,
};
pub use screens::{
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize,
@@ -128,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
+9 -5
View File
@@ -121,7 +121,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
name: "outlineText",
ty: "boolean",
default: "false",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is always outlines",
},
];
@@ -146,10 +146,13 @@ pub(super) struct Extras {
/// are left to [`encode`]).
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> {
let o: NativeOptions = super::encode::options(f, p)?;
let mut so = SaveOptions::for_doc(doc);
so.compress = o.compress.unwrap_or(false);
so.version = o.version.unwrap_or(vectorcraft_format::VERSION);
so.include_linked = o.include_linked;
// Pretty or compact is decided by [`encode_to`] on the document it writes.
let mut so = SaveOptions {
compress: o.compress.unwrap_or(false),
version: o.version.unwrap_or(vectorcraft_format::VERSION),
include_linked: o.include_linked,
..SaveOptions::default()
};
if o.embed_profiles.unwrap_or(true) {
so.profiles = embedded_profiles(doc);
}
@@ -190,6 +193,7 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp
}
other => other,
};
so.pretty = so.pretty_fits(&native);
vectorcraft_format::save_to(&native, &so, w).map_err(|e| bad(cmd, e.to_string()))
}
+180 -16
View File
@@ -14,7 +14,8 @@ use vectorcraft_doc::Document;
use super::super::*;
use super::{
ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded,
ARTBOARD_PARAMS, ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref,
write_encoded,
};
use crate::{DocState, Prefs};
@@ -94,6 +95,9 @@ pub struct SavePlan {
/// The `modified` param (as `date_param` reads it): the File Info date a Save or Save As to a file
/// stamps; `None`: the time it is written.
pub modified: Option<Option<i64>>,
/// Warnings about the params themselves: each native option (`compress`, `version`,
/// `preview`) given beside the path that the format doesn't read.
pub notes: Vec<String>,
}
impl SavePlan {
@@ -233,6 +237,14 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
(_, None, None) => checked(cmd, st.format)?,
};
let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path };
if let Some((key, top, inner)) = disagreeing_option(p, is_svg(format)).map_err(|e| bad(cmd, e))? {
return Err(bad(cmd, format!("`{key}: {top}` and `options.{key}: {inner}` disagree: give the option once")));
}
let notes = NATIVE_OPTIONS
.iter()
.filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k))
.map(|k| format!("`{k}` was not used: {} files don't read it", format.label))
.collect();
let mut given: Map<String, Value> = match p.get("options") {
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
None | Some(Value::Null) => Map::new(),
@@ -253,7 +265,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
}
}
// None given: the ones the document was last saved with in this format.
let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given };
let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
// Save writes the document's own file; the other modes only where they are told to.
let path =
path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten());
@@ -272,12 +284,141 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
if let Some(path) = &path {
super::check_not_lossy_overwrite(st, path, p, cmd)?;
}
Ok(SavePlan { mode, path, format, options, name, folder, modified })
Ok(SavePlan { mode, path, format, options, name, folder, modified, notes })
}
/// The native save options, which a save also takes beside the path.
const NATIVE_OPTIONS: [&str; 3] = ["compress", "version", "preview"];
/// The first option `p` gives both beside the path and in `options` with different values, as
/// `(key, top-level value, options value)`. For an SVG save (`svg`), an SVG option's value on
/// either side is the one its `svg: {…}` object gives, else the flat one.
fn disagreeing_option(p: &Value, svg: bool) -> std::result::Result<Option<(String, Value, Value)>, String> {
let Some(inner) = p.get("options").filter(|o| o.is_object()) else { return Ok(None) };
let given = |m: &Value| -> std::result::Result<Map<String, Value>, String> {
let mut out: Map<String, Value> = m
.as_object()
.into_iter()
.flatten()
.filter(|(k, v)| !v.is_null() && !matches!(k.as_str(), "path" | "format" | "options") && !(svg && k.as_str() == "svg"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if svg {
out.extend(super::svg_options(m)?);
}
Ok(out)
};
let top = given(p)?;
Ok(given(inner)?.into_iter().find_map(|(k, v)| {
let t = top.get(&k).filter(|t| !same_value(t, &v))?.clone();
Some((k, t, v))
}))
}
/// `a == b`, with a whole number and a float of the same value (`3` and `3.0`) the same.
fn same_value(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => x == y || ((x.is_f64() || y.is_f64()) && x.as_f64() == y.as_f64()),
_ => a == b,
}
}
/// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`)
/// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is
/// left out, and one that moved is named by its new number. When none of the artboards the save
/// used is left, the pick is left out, and a save without options writes the artboards it writes
/// when none is named. `range` `"all"` stays as it is. Artboards given to a save are still checked.
pub fn remembered_save_options(st: &DocState) -> Map<String, Value> {
let mut options = st.save_options.clone();
let ids: Vec<u32> = st.doc.artboards.iter().map(|a| a.id).collect();
// The encoders refuse a document without artboards themselves.
if ids.is_empty() {
return options;
}
// Artboard `i` of the save, where it is now (by number when the save's artboards are unknown).
let then = &st.save_artboards;
let count = if then.is_empty() { ids.len() } else { then.len() };
let now = |i: usize| match then.get(i) {
Some(id) => ids.iter().position(|x| x == id),
None => (then.is_empty() && i < ids.len()).then_some(i),
};
// Each pick that names artboards: `Some` of where they are now (empty: all gone).
let mut moved: Vec<(&str, Option<Vec<usize>>)> = vec![];
for k in ARTBOARD_PARAMS {
let named = match (k, options.get(k)) {
("range", Some(Value::String(r))) if r.trim().is_empty() || r.trim().eq_ignore_ascii_case("all") => continue,
("range", Some(Value::String(r))) => match vectorcraft_doc::range::parse_range(r, count) {
Ok(v) => v,
Err(_) => {
moved.push((k, Some(vec![])));
continue;
}
},
("artboards", Some(v @ Value::Array(_))) => match serde_json::from_value::<Vec<usize>>(v.clone()) {
Ok(v) => v,
Err(_) => continue,
},
("artboard", Some(v)) => match v.as_u64().and_then(|i| usize::try_from(i).ok()) {
Some(i) => vec![i],
None => continue,
},
_ => continue,
};
let there: Vec<usize> = named.iter().filter_map(|i| now(*i)).collect();
// A range that reads differently against the artboards there are now (`"2-"` once one
// was added) is written out as the artboards it named.
let reads = match (k, options.get(k)) {
("range", Some(Value::String(r))) => vectorcraft_doc::range::parse_range(r, ids.len()).ok(),
_ => Some(named),
};
moved.push((k, (reads.as_ref() != Some(&there)).then_some(there)));
}
// The pick a save uses (as `ArtboardPick::resolve` reads it: `range`, then `artboards`, then
// `artboard`) is gone: so is the rest.
let used = ["range", "artboards", "artboard"].into_iter().find(|k| match options.get(*k) {
Some(Value::String(r)) => !r.trim().is_empty(),
Some(v) => !v.is_null(),
None => false,
});
let gone = |k: &str| moved.iter().any(|(m, there)| *m == k && there.as_ref().is_some_and(Vec::is_empty));
if used.is_some_and(gone) {
for k in ARTBOARD_PARAMS {
options.remove(k);
}
return options;
}
for (k, there) in moved {
let Some(there) = there else { continue };
let value = match (k, there.first()) {
(_, None) => None,
("range", Some(_)) => Some(json!(range_text(&there))),
("artboards", Some(_)) => Some(json!(there)),
(_, Some(i)) => Some(json!(i)),
};
match value {
Some(v) => _ = options.insert(k.into(), v),
None => _ = options.remove(k),
}
}
options
}
/// 0-based artboard indices as a 1-based range in the same order, runs joined (`"1-2, 5"`).
fn range_text(picked: &[usize]) -> String {
let mut runs: Vec<(usize, usize)> = vec![];
for &i in picked {
match runs.last_mut() {
Some((_, end)) if *end + 1 == i => *end = i,
_ => runs.push((i, i)),
}
}
let run = |&(a, b): &(usize, usize)| if a == b { format!("{}", a + 1) } else { format!("{}-{}", a + 1, b + 1) };
runs.iter().map(run).collect::<Vec<_>>().join(", ")
}
/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General
/// settings (`createLayers`…) and presets.
fn reads_option(f: &Format, key: &str) -> bool {
/// settings (`createLayers`…) and presets. A save drops each option its format doesn't read.
pub fn reads_option(f: &Format, key: &str) -> bool {
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
@@ -426,6 +567,15 @@ fn blank_pages(doc: &Document) -> Document {
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
/// `e`, an error of the export encoders, under the save command `cmd` instead of
/// `document.export`.
fn named_for_save(cmd: &str, e: crate::EngineError) -> crate::EngineError {
match e {
crate::EngineError::BadParams { cmd: c, msg } if c == "document.export" => bad(cmd, msg),
e => e,
}
}
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
/// same file).
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
@@ -509,7 +659,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
if matches!(plan.format.id, "vectorcraft" | "template") {
// Not remembered with the options: the preference decides each time it isn't given.
params = with_compression_pref(&s.prefs, &params);
super::native::save_options(cmd, plan.format, &doc, &params)?;
super::native::save_options(cmd, plan.format, &doc, &params).map_err(|e| named_for_save(cmd, e))?;
}
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
@@ -547,24 +697,38 @@ impl SaveJob {
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
/// applied yet…).
/// applied yet…, then the plan's notes on its params).
pub fn encode(&self) -> Result<Encoded> {
let mut enc = self.encode_one(&self.doc)?;
for &b in &self.boards {
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
}
// Lost artboards first: the status bar shows only the first warning.
enc.warnings.splice(0..0, self.format_losses());
enc.warnings.extend(self.plan.notes.iter().cloned());
Ok(enc)
}
/// What the file loses against the document: the artboards an SVG file leaves out first (the
/// status bar shows only the first warning), then what the format loses.
fn format_losses(&self) -> impl Iterator<Item = String> {
let f = self.plan.format;
let lost = if is_svg(f) { super::svg::lost_artboards_warning(&self.doc, &self.params, f.label) } else { None };
enc.warnings.splice(0..0, lost.into_iter().chain(fidelity_warning(f)));
Ok(enc)
lost.into_iter().chain(fidelity_warning(f))
}
/// The warnings known before the file is encoded, in the order a written file's reply gives
/// them: what the format loses, then the plan's notes on its params (a reply sent before the
/// file is written, as Background Save's, gives these).
pub fn known_warnings(&self) -> Vec<String> {
self.format_losses().chain(self.plan.notes.iter().cloned()).collect()
}
/// Encode one file of `doc` in the job's format.
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params) } else { encode_all(doc, f.id, &self.params) }
.map_err(|e| named_for_save(cmd, e))?;
// A legacy .ai file per artboard.
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?;
if enc.files.len() != 1 && !per_artboard {
@@ -650,8 +814,7 @@ impl SaveJob {
};
let r = stream(path, &mut fill);
r.map_err(|e| failed.take().unwrap_or(e))?;
let warnings: Vec<String> = fidelity_warning(f).into_iter().collect();
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": warnings }))
Ok(json!({ "path": path, "bytes": written, "format": f.id, "warnings": self.known_warnings() }))
}
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
@@ -664,6 +827,7 @@ impl SaveJob {
st.path = Some(path);
st.format = self.plan.format.id;
st.save_options = self.plan.options;
st.save_artboards = self.snapshot.artboards.iter().map(|a| a.id).collect();
st.converted = false;
st.recovered = false;
if let Some(at) = self.stamped {
@@ -756,14 +920,14 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
let f = format(id)
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id) || TEMPLATE_FORMATS.contains(&f.id))
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
let saved = st.filter(|d| d.format == f.id).map(remembered_save_options);
// A native save compresses as Use Compression says, unless told otherwise.
let prefs = with_compression_pref(&s.prefs, &json!({}));
let mut v = f.to_json();
if let Some(options) = v["options"].as_object_mut() {
for (name, o) in options.iter_mut() {
let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]);
o["value"] = saved.and_then(|m| m.get(name)).unwrap_or(default).clone();
o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone();
}
}
v["saveFormats"] =
@@ -778,7 +942,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead, compress?: bool (native, also in options: gzip; default: the useCompression preference), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+52
View File
@@ -702,6 +702,58 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
let _ = std::fs::remove_dir_all(&d);
}
/// An option given beside the path (an SVG option flat or in `svg`) and in `options` with different
/// values is refused before anything is written; `compress`, `version` or `preview` beside the path of a format that doesn't
/// read it is named in the warnings.
#[test]
fn a_save_refuses_disagreeing_options_and_names_unread_native_ones() {
let d = tmp_dir("save-options");
let mut s = session(200.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
let path = d.join("both.vectorcraft").to_string_lossy().to_string();
for cmd in ["document.save", "file.saveAs", "file.saveCopy", "file.saveAsTemplate"] {
let e = s.execute(cmd, &json!({"path": path, "compress": true, "options": {"compress": false}})).unwrap_err().to_string();
assert!(e.contains("`compress: true` and `options.compress: false` disagree") && e.contains(&format!("`{cmd}`")), "{cmd}: {e}");
}
assert!(!std::path::Path::new(&path).exists(), "nothing is written");
assert!(!d.join("both.vctemplate").exists(), "nor a template");
// An SVG option given in `svg: {…}` or in `options.svg` counts as given there.
let svg = d.join("a.svg").to_string_lossy().to_string();
for p in [
json!({"svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}),
json!({"hiddenLayers": false, "options": {"svg": {"hiddenLayers": true}}}),
json!({"hiddenLayers": true, "svg": {"hiddenLayers": false}, "options": {"hiddenLayers": true}}),
] {
let mut p = p;
p["path"] = json!(svg);
let e = s.execute("file.saveCopy", &p).unwrap_err().to_string();
assert!(e.contains("`hiddenLayers: false` and `options.hiddenLayers: true` disagree") && e.contains("`file.saveCopy`"), "{p}: {e}");
}
assert!(!std::path::Path::new(&svg).exists(), "nothing is written");
// Agreeing values, or one of them null, save as asked; `3` and `3.0` agree.
for p in [
json!({"compress": true, "options": {"compress": true}}),
json!({"compress": null, "options": {"compress": true}}),
json!({"compress": true, "version": 3, "options": {"compress": true, "version": 3.0}}),
] {
let mut p = p;
p["path"] = json!(path);
let r = s.execute("file.saveCopy", &p).unwrap();
assert_eq!(r["warnings"], json!([]), "{p}: {r}");
assert_eq!(std::fs::read(&path).unwrap()[..2], [0x1f, 0x8b], "{p}");
}
// An SVG file reads none of the native options; a .ai file reads `compress` only.
let r = s.execute("file.saveCopy", &json!({"path": svg, "compress": true, "version": 3, "preview": null})).unwrap();
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(w.contains(&"`compress` was not used: SVG files don't read it"), "{w:?}");
assert!(w.contains(&"`version` was not used: SVG files don't read it") && !w.iter().any(|w| w.starts_with("`preview`")), "{w:?}");
assert!(std::fs::read(&svg).unwrap().starts_with(b"<"), "plain SVG");
let r = s.execute("file.saveCopy", &json!({"path": d.join("a.ai").to_string_lossy(), "compress": false, "version": 3})).unwrap();
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(w.iter().any(|w| w.starts_with("`version` was not used")) && !w.iter().any(|w| w.starts_with("`compress`")), "{w:?}");
let _ = std::fs::remove_dir_all(&d);
}
/// A native save whose file write fails partway (a full disk) leaves the file as it was, and no
/// temporary file behind.
#[test]
@@ -82,6 +82,37 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() {
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
/// A `.ai` file opens with the swatches its palette holds, spot colours the art doesn't use and colour
/// groups included, in place of the default swatches; art painted with Registration (`XZ`) still
/// opens from the editing data.
#[test]
fn an_ai_file_opens_with_the_swatches_of_its_palette() {
let palette = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
// The square the page draws, in the red ink, after a stroke colour of Registration.
let art = format!("0 0 0 0 0 0 0 ([Registration]) 0 1 XZ\n0.06 0.98 1 0 (Red ink) 0 x\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
let data = ai::editing_data(200.0, 100.0, &format!("{palette}{}", ai::layer("Art", &art)));
let mut s = Session::new();
let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art"], "opened from the editing data: {r}");
let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect();
assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"]);
let pink = doc.swatch("Pink ink").unwrap();
assert!(pink.spot && pink.global);
assert_eq!(pink.paint.color(), Some(vectorcraft_color::Color::lab(81.96, 21.0, -8.0)), "a Lab ink keeps its Lab values");
let groups: Vec<&str> = doc.swatch_groups.iter().map(|g| g.name.as_str()).collect();
assert_eq!(groups, ["Inks"]);
assert!(doc.swatch("Yellow ink").is_some_and(|s| s.spot), "an ink the art doesn't use");
let red = &doc.layers[0].children().unwrap()[0];
assert!(
matches!(red.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Solid { swatch: Some(n), .. }) if n == "Red ink"),
"{:?}",
red.appearance.fill()
);
}
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
#[test]
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
+35 -2
View File
@@ -80,8 +80,13 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": true, "previewFormat": "none"})).unwrap();
let files = r["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]);
for f in files {
assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]);
// The central file comes first: the whole document, bounded by the visible art of both artboards.
let central = b64(&files[0]);
assert_eq!(dsc(&postscript(&central), "BoundingBox").as_deref(), Some(format!("10 60 {} 90", second + 40.0).as_str()));
let native = vectorcraft_format::load(&vectorcraft_eps::native(&central).unwrap()).unwrap();
assert_eq!((native.artboards.len(), native.layers[0].children().unwrap().len()), (2, 2));
for f in &files[1..] {
let bytes = b64(f);
let ps = postscript(&bytes);
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("0 0 100 100"));
@@ -89,6 +94,10 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
assert_eq!(native.artboards.len(), 1);
}
// Named artboards, two or more, get the central file too.
let r = s.execute("document.exportEps", &json!({"artboards": [1, 0], "previewFormat": "none"})).unwrap();
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Untitled-1.eps", "Untitled-1_Artboard-2.eps", "Untitled-1_Artboard-1.eps"]);
// A range names the artboards (useArtboards implied); one file keeps the path's name.
let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap();
assert!(r.get("files").is_none());
@@ -97,6 +106,30 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
// Without art there is nothing to bound.
let mut empty = session(100.0, 100.0, 1);
assert!(empty.execute("document.exportEps", &json!({})).unwrap_err().to_string().contains("nothing to export"));
// Artboards without art still get their files, but no central file.
let mut empty = session(100.0, 100.0, 2);
let r = empty.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "none"})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
}
#[test]
fn use_artboards_writes_the_central_file_beside_the_artboard_files() {
let mut s = session(100.0, 100.0, 2);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let dir = std::env::temp_dir().join(format!("vc-epsboards-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("logo.eps").to_string_lossy().to_string();
s.execute("document.exportEps", &json!({"path": path, "useArtboards": true, "previewFormat": "none"})).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
written.sort();
assert_eq!(written, ["logo.eps", "logo_Artboard-1.eps", "logo_Artboard-2.eps"]);
let central = std::fs::read(&path).unwrap();
assert_eq!(dsc(&postscript(&central), "BoundingBox").as_deref(), Some("10 60 40 90"));
let _ = std::fs::remove_dir_all(dir);
// Export for Screens still writes one EPS per artboard, with no central file.
let rows = json!({"formats": [{"format": "eps", "useArtboards": true}], "settings": {"eps": {"useArtboards": true}}});
let r = s.execute("document.exportForScreens", &rows).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
}
#[test]
+9 -2
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay; a frame of threaded text that is outlined or flattened leaves its thread with the text it shows → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_doc,
flatten
),
@@ -268,7 +268,14 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
}
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?;
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| {
let threaded = super::threads::frames(d);
let out = apply(d, sel, plan, &o)?;
// Frames of threaded text that were outlined or flattened leave their threads with the
// text they show.
super::threads::leave_threads(d, &threaded);
Ok(out)
})?;
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options });
if let Some(w) = warning {
out["warning"] = json!(w);
+3 -1
View File
@@ -586,7 +586,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
let item = item.of_kind(s, !stroke);
let targets = match item {
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
ItemTarget::Top => {
leaf_targets(s, &super::given_objects(s, p, C)?.map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?
}
_ => item.targets(s, p)?,
};
if targets.is_empty() {
+2 -2
View File
@@ -806,7 +806,7 @@ fn column_art(
if length(rect) <= 1e-9 || !length(rect).is_finite() {
return None;
}
let cost = c.art.count();
let cost = c.art.count_with_masks();
// A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved
// by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when
// `keep_low` (two objects more).
@@ -2076,7 +2076,7 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("a document keeps at most {} graph designs", GraphDesign::MAX)));
}
let art = super::brushsym::selection_art(s, p)?.ok_or_else(|| bad(C, "select the art for the design"))?;
if art.count() > GraphDesign::MAX_NODES {
if art.count_with_masks() > GraphDesign::MAX_NODES {
return Err(bad(C, format!("a design holds at most {} objects", GraphDesign::MAX_NODES)));
}
// A graph saved as a design is kept as the art it draws.
+5
View File
@@ -768,6 +768,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
let ch = d.children_mut(parent)?;
let index = index.min(ch.len());
ch.splice(index..index, nodes);
// Copied type whose layout caches `reid` dropped has them again.
if copy {
super::typecmd::refresh_bounds_of(d, &out);
}
sel.prune(d);
sel.deselect_uneditable(d);
Ok(out)
@@ -866,6 +870,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let id = d.artboards.remove(i).id;
d.retire_artboard_id(id);
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
Ok(())
+4
View File
@@ -388,6 +388,10 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
let ch = d.children_mut(Some(parent))?;
let at = index.min(ch.len());
ch.splice(at..at, layers);
// Copied type whose layout caches `reid` dropped has them again.
if build {
super::typecmd::refresh_bounds_of(d, &new);
}
sel.prune(d);
Ok(new)
})?;
+9 -1
View File
@@ -96,7 +96,15 @@ impl Visitor for LinkFiles {
if let Some((Some(saved_size), Some(saved_hash))) = self.saved.get(i)
&& *saved_size == size
&& size <= MAX_HASHED
&& hash.get_or_insert_with(|| std::fs::File::open(path).and_then(vectorcraft_doc::links::hash_reader).ok()).as_ref()
&& hash
.get_or_insert_with(|| {
// Only the size looked at is read: a file growing meanwhile no longer
// matches, rather than being hashed to its new end.
let file = std::fs::File::open(path).ok()?;
let (hash, read) = vectorcraft_doc::links::hash_reader_within(file, size).ok()?;
(read == size).then_some(hash)
})
.as_ref()
== Some(saved_hash)
{
items.push(n + i);
+32 -5
View File
@@ -231,7 +231,12 @@ impl Found {
/// `base` pointing at this file as read now (`bytes`).
fn link(&self, bytes: &[u8], base: &LinkInfo) -> LinkInfo {
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash_bytes(bytes)), ..base.clone() }
self.link_hashed(hash_bytes(bytes), base)
}
/// [`Self::link`] with the bytes' hash already taken.
fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo {
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() }
}
}
@@ -542,8 +547,11 @@ fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
/// Replace image `id` with the raster at `path`, keeping the object's bounds (what an image
/// variable does on `dataset.select`). The file is linked; a missing or unreadable path is an
/// error so the dataset application can skip that binding.
pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str) -> Result<()> {
let path = absolute_path(path);
pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_path: Option<&str>) -> Result<()> {
// A path stored in the document (a data set's value) is followed as its other stored paths
// are: on this computer or the document's own share only, so choosing a data set can't make
// the app connect to another computer.
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
let bytes = read_file(&path)?;
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
install(d, &[id], read, Some(Preserve::Bounds))
@@ -618,6 +626,10 @@ impl Resolved {
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
let mut out = Resolved::default();
// The image (with its preview) and hash of each file read for a preview here, by path: images
// of other keys linking to the same file share them, rather than each reading, decoding and
// keeping a copy of its own.
let mut read_images: BTreeMap<String, (ImageBlob, String)> = BTreeMap::new();
for g in groups(d, None) {
let row = rows(std::iter::once(&g), doc_path);
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
@@ -655,14 +667,24 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
}
(Status::Ok, Some(mut f)) => {
let preview = previewing(d, &g.key);
if let Some((blob, hash)) = read_images.get(&f.path).filter(|_| preview && !eps_preview).cloned() {
store_image(d, &g.key, blob, true);
let link = f.link_hashed(hash, &g.link);
if link != g.link {
set_links(d, &g.ids, &link);
}
continue;
}
// Read when the images show the preview, or when the file's details changed.
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
if preview && !eps_preview {
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, fileio::raster_image(b).ok()?))) else {
out.missing.extend(row);
continue;
};
store_image(d, &g.key, img.blob, true);
let blob = img.blob.with_proxy();
read_images.insert(f.path.clone(), (blob.clone(), hash_bytes(b)));
store_image(d, &g.key, blob, true);
} else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) {
// Linked before links kept a preview.
let b = b.clone().with_proxy();
@@ -1266,6 +1288,11 @@ fn placement_json(o: PlacementOptions) -> Value {
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
// Within what the caller that opened the document may read, whoever asks.
crate::file_access::confine(st.opened_by.as_ref(), || file_fields_here(st, id, l, out));
}
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
let doc_path = st.path.as_deref();
let path = fileio::document_path(&l.path, doc_path).ok();
+2
View File
@@ -140,6 +140,8 @@ pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result
let copy = d.reid(part);
ids.push(d.insert(Some(layer), usize::MAX, copy)?);
}
// Copied type whose layout caches `reid` dropped has them again.
super::typecmd::refresh_bounds_of(d, &ids);
d.mask_edit = Some(MaskEdit { object: id, layer });
sel.set(ids);
Ok(layer)
+3 -1
View File
@@ -969,10 +969,12 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
if b.width() <= 0.0 || b.height() <= 0.0 {
continue;
}
// Named one past the highest artboard id the document has, whatever id it is given.
let number = d.artboards.iter().map(|a| a.id).max().map_or(1, |m| m.saturating_add(1));
let aid = d.next_artboard_id();
d.artboards.push(vectorcraft_doc::Artboard {
id: aid,
name: format!("Artboard {aid}"),
name: format!("Artboard {number}"),
rect: b,
locked: false,
show_center_mark: false,
+16
View File
@@ -367,6 +367,22 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
}
/// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads
/// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error,
/// never the selection. `None` when it gives neither (or gives them as `null`).
pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> {
if p.get("ids").is_some_and(|v| !v.is_null()) {
return checked_ids_param(s, p, "ids", command).map(Some);
}
match p.get("id").filter(|v| !v.is_null()) {
None => Ok(None),
// A step reference a batch couldn't resolve, as `targets` reports it, under the command.
Some(Value::String(r)) if r.starts_with('$') => {
Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
}
Some(v) => Ok(Some(vec![checked_id(s, v, command)?])),
}
}
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
pub(crate) fn index(v: u64) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
+1
View File
@@ -517,6 +517,7 @@ pub const CATEGORIES: &[Category] = &[
("Logo 1000×1000", 1000.0, 1000.0, PX),
("Business Card 3.5 × 2 in", 3.5, 2.0, IN),
("Business Card 85 × 55 mm", 85.0, 55.0, MM),
("Business Card 91 × 55 mm", 91.0, 55.0, MM),
("Letterhead Letter", 8.5, 11.0, IN),
("Letterhead A4", 210.0, 297.0, MM),
("Envelope DL", 220.0, 110.0, MM),
+2 -4
View File
@@ -1139,16 +1139,14 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
}
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let guides_locked = s.menu.guides_locked;
let menu = s.menu.clone();
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
super::distortcmds::finish_edit_text(st, i);
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
st.selection.set([target]);
// A guide path isolated while Lock Guides is on isn't selected (#1534).
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
// The art on a locked artboard can't be selected.
st.protect_locked_artboards(&[], &Default::default(), None);
}
+2
View File
@@ -237,6 +237,8 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
released.put(d, *id, parts)?;
}
released.finish(d)?;
// Copied type whose layout caches `reid` dropped has them again.
super::typecmd::refresh_bounds_of(d, &out);
sel.set(out);
Ok(())
})?;
+1 -1
View File
@@ -684,7 +684,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
let stroke = stroke_param(s, p, cmd)?;
let ids = match ids_param(p, "ids") {
let ids = match super::given_objects(s, p, cmd)? {
Some(ids) => ids,
None => super::edit::selected_roots(s)?,
};
File diff suppressed because one or more lines are too long
+27 -1
View File
@@ -161,6 +161,32 @@ pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
}
/// Does the description `doc` take `id` as `ids` of one object (`{ids?|id?}`, `{id?, ids?: …
/// (instead of id)}`, `{id} or {ids: […]}`): it names both keys, and no params object of it names
/// `ids` beside a required `id`, which is something else (`plugin.run {id, ids?}`: the plug-in's)?
pub(crate) fn id_stands_for_ids(doc: &'static str) -> bool {
fn head(alternative: &str) -> &str {
alternative.split([':', '=', '(']).next().unwrap_or_default().trim()
}
if !takes_key(doc, "ids") || !takes_key(doc, "id") {
return false;
}
let mut rest = doc;
while let Ok((body, after)) = object_body(rest) {
let entries = split_top(body, ',');
let required_id = entries.iter().any(|e| split_top(e, '|').len() == 1 && head(e) == "id");
let names_ids = entries.iter().any(|e| split_top(e, '|').iter().any(|a| head(a).trim_end_matches('?').trim_end() == "ids"));
if required_id && names_ids {
return false;
}
match after.trim_start().strip_prefix("or ").or_else(|| after.trim_start().strip_prefix("| ")).filter(|r| r.starts_with('{')) {
Some(next) => rest = next,
None => break,
}
}
true
}
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
fn distance(a: &str, b: &str) -> usize {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
@@ -202,7 +228,7 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
/// them, or all of them.
fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
bad(
id,
match nearest(key, keys) {
+39 -3
View File
@@ -126,8 +126,13 @@ pub(crate) fn document_art(
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
}
let mut l = fileio::load(name, bytes)?;
// Its linked images show their files; its placed documents stay as they are.
super::super::links::resolve(&mut l.doc, path, false, false);
// Its linked images show their files; its placed documents stay as they are, unless their
// stored bytes are lost, when resolving reads their files, which can place this one again.
// A document already being read further up, or past [`MAX_NESTED_READS`] of them, has its links
// left as stored.
if let Some(_reading) = Reading::enter(bytes) {
super::super::links::resolve(&mut l.doc, path, false, false);
}
l.doc.drop_edit_modes();
let n = l.doc.artboards.len();
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
@@ -140,6 +145,33 @@ pub(crate) fn document_art(
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
}
/// The most placed documents read inside one another while their links are resolved.
const MAX_NESTED_READS: usize = 8;
std::thread_local! {
/// The hashes of the documents whose links are being resolved on this thread, outermost first.
static READING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// A document's links being resolved ([`READING`]): the entry goes when this does.
struct Reading;
impl Reading {
/// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are.
fn enter(bytes: &[u8]) -> Option<Self> {
let hash = hash_bytes(bytes);
READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading)
}
}
impl Drop for Reading {
fn drop(&mut self) {
READING.with_borrow_mut(|r| {
r.pop();
});
}
}
/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads).
fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
let mut children: Vec<Arc<Node>> = nodes.into_iter().map(Arc::new).collect();
@@ -159,8 +191,12 @@ pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Documen
/// `p`'s file read again, while it is the file it was read from (how the document model gets it
/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]).
pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
// The stored path is followed as stored paths are, but with no document to share a network
// folder with: a file on another computer is never read here, even one resolving refused
// (which leaves the stored path in place), so embedding or exporting can't connect to it.
let path = fileio::document_path(&p.link.path, None).ok()?;
// One byte over the limit tells a file that is too big, without reading it all.
let bytes = fileio::read_file_head(&p.link.path, MAX_FILE + 1).ok()?;
let bytes = fileio::read_file_head(&path, MAX_FILE + 1).ok()?;
if bytes.len() > MAX_FILE {
return None;
}
+17 -4
View File
@@ -77,6 +77,10 @@ const MAX_BASED_ON: usize = 32;
const MAX_FONTS: usize = 4096;
/// The most bytes of a font name kept.
const MAX_NAME: usize = 256;
/// The most bytes of a style id: a longer one (Word's are under 256 characters) is ignored, both
/// where it is defined and where it is used, since every paragraph and run looks its style up by
/// it.
const MAX_STYLE_ID: usize = 256;
/// The most paragraphs placed type takes from one Word document.
const MAX_PARAGRAPHS: usize = 100_000;
/// The most runs of one character formatting placed type takes from one Word document.
@@ -484,7 +488,7 @@ impl Reader {
}
}
Some("style") if self.styles.len() < MAX_STYLES => {
let Some(id) = attr(n, "styleId") else { continue };
let Some(id) = attr(n, "styleId").filter(|id| id.len() <= MAX_STYLE_ID) else { continue };
let default = match attr(n, "type") {
Some("paragraph") => Some(&mut self.default_style),
Some("character") => Some(&mut self.default_char_style),
@@ -495,7 +499,7 @@ impl Reader {
}
let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default();
let para = child(n, "pPr").map(para_props).unwrap_or_default();
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string);
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).filter(|v| v.len() <= MAX_STYLE_ID).map(str::to_string);
self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para });
}
_ => {}
@@ -637,7 +641,12 @@ impl Reader {
fn paragraph(&mut self, p: Node, depth: usize) {
self.flush();
let ppr = child(p, "pPr");
let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone());
let style = ppr
.and_then(|n| child(n, "pStyle"))
.and_then(|n| attr(n, "val"))
.filter(|v| v.len() <= MAX_STYLE_ID)
.map(str::to_string)
.or_else(|| self.default_style.clone());
let mut para = Para::default();
para.apply(&self.default_para);
let mut base = Chars::default();
@@ -688,7 +697,11 @@ impl Reader {
fn run(&mut self, r: Node) {
let rpr = child(r, "rPr");
let mut chars = self.base;
let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref());
let style = rpr
.and_then(|n| child(n, "rStyle"))
.and_then(|n| attr(n, "val"))
.filter(|v| v.len() <= MAX_STYLE_ID)
.or(self.default_char_style.as_deref());
for s in self.chain(style) {
chars.apply(&s.run, true);
}
+3 -1
View File
@@ -543,7 +543,9 @@ impl Session {
self.prefs = p;
if fonts_folder_changed {
let folder = self.prefs.fonts_folder.trim();
vectorcraft_text::set_user_font_dirs(if folder.is_empty() { vec![] } else { vec![folder.into()] });
// A folder an automation caller chose is read without following its links out of it.
let confined = crate::file_access::current().is_some();
vectorcraft_text::set_user_font_dirs_confined(if folder.is_empty() { vec![] } else { vec![folder.into()] }, confined);
// Once the fonts were scanned, scan again now: the folder's fonts appear (or go) in the
// font menus, and type in them lays out again. The first scan reads it anyway.
if vectorcraft_text::FontDb::global().installed_fonts_changed() {

Some files were not shown because too many files have changed in this diff Show More