Merge upstream/main into fix/2414-svg-classes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-26 20:47:10 +01:00
co-authored by Claude Sonnet 5.5
400 changed files with 35996 additions and 8793 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+1 -1
View File
@@ -29,5 +29,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
run-package-check: true
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
with:
app-name: vectorcraft
app-title: VectorCraft
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+19 -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 |
@@ -350,20 +352,20 @@ The shared PDF backend keeps the existing standard-font and CMap features. These
| Crate / file | Author | Source | Licence | SHA-256 | Use |
| --- | --- | --- | --- | --- | --- |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
Generated
+1638 -190
View File
File diff suppressed because it is too large Load Diff
+33 -17
View File
@@ -4,21 +4,22 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.16.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
# 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 = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -102,10 +103,10 @@ tiff = "0.11"
zune-jpeg = "0.5"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
clipboard-win = "5.4"
@@ -140,9 +141,24 @@ lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" }
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
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" }
+20 -2
View File
@@ -77,10 +77,10 @@ Shared PDF backend
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0
and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision
2eca9b3a53a98ac52efed2a27353c6a6927ead10. Their upstream project is
a0916808f7d78bed8c7b90cdf937176015454a7f. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/2eca9b3a53a98ac52efed2a27353c6a6927ead10/vendor/README.craft-reader.md.
https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/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.
+4 -3
View File
@@ -32,10 +32,11 @@ log = { workspace = true }
[target.'cfg(windows)'.dependencies]
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
# DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs,
# shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR
# Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] }
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
+74 -11
View File
@@ -4,7 +4,7 @@
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000] [--deep]
@@ -90,7 +90,7 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
@@ -103,8 +103,11 @@ USAGE:
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy
even where that draws differently from its PDF part or page (which can be older), as long as
it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
it can be read. A PDF OUT omits the embedded native document unless --preserve-editing is
given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the
attachment is written the result's warnings say so (it is left out, with its own warning, for
--artboard or a --range without every artboard).
The result has the export's warnings, and importWarnings for what reading IN left out.
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
--hidden-objects (default layer: a hidden layer above each layer's hidden objects; omit;
@@ -134,6 +137,29 @@ fn usage() -> String {
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
}
/// Does a subcommand's `args` ask for the usage? `--help` or `-h` counts where an option goes, not as
/// the value of one of the `valued` options before it, so `run --cmd -h` keeps `-h` for `--cmd`. The
/// subcommands check this before they parse or read anything else.
fn help_requested(args: &[String], valued: &[&str]) -> bool {
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--help" | "-h" => return true,
a if valued.contains(&a) => {
it.next();
}
_ => {}
}
}
false
}
/// Prints the usage, for `help`, `--help` and `-h`.
fn help() -> Result<(), String> {
out!("{}", usage());
Ok(())
}
fn main() -> ExitCode {
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
@@ -143,6 +169,9 @@ fn main() -> ExitCode {
prefs_dir::install_fonts();
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
// `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the
// usage. The other subcommands check for it themselves.
Some("commands" | "info") if help_requested(&args[1..], &[]) => help(),
Some("mcp") => mcp(&args[1..]),
Some("run") => run(&args[1..]),
Some("commands") => commands(),
@@ -150,10 +179,7 @@ fn main() -> ExitCode {
Some("info") => info(&args[1..]),
Some("bench") => bench(&args[1..]),
Some("perf") => perf::run(&args[1..]),
Some("-h" | "--help" | "help") | None => {
out!("{}", usage());
Ok(())
}
Some("-h" | "--help" | "help") | None => help(),
Some("-V" | "--version") => {
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
@@ -180,6 +206,9 @@ fn with_stores(mut h: Headless) -> Headless {
}
fn mcp(args: &[String]) -> Result<(), String> {
if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) {
return help();
}
let mut connect: Option<String> = None;
let mut headless = false;
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
@@ -207,7 +236,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
.into());
}
let backend: Box<dyn Backend> = if let Some(roots) = roots {
@@ -243,9 +272,13 @@ fn commands() -> Result<(), String> {
}
fn convert(args: &[String]) -> Result<(), String> {
let valued = ["--scale", "-s", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"];
if help_requested(args, &valued) {
return help();
}
let mut files = vec![];
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
(1.0f64, None::<u64>, None::<String>, false, false, false);
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data, mut preserve_editing) =
(1.0f64, None::<u64>, None::<String>, false, false, false, false);
let mut decimals = None::<u64>;
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
// `transparentImages`); the engine checks their values.
@@ -260,6 +293,7 @@ fn convert(args: &[String]) -> Result<(), String> {
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
"--replace-lossy" => replace_lossy = true,
"--editing-data" => editing_data = true,
"--preserve-editing" => preserve_editing = true,
"--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)),
"--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)),
"--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)),
@@ -289,6 +323,9 @@ fn convert(args: &[String]) -> Result<(), String> {
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
if vectorcraft_engine::cmd::fileio::format_for_name(&output).is_some_and(|f| f.id == "pdf") {
params["preserveEditing"] = json!(preserve_editing);
}
("document.export", params)
};
for (key, value) in legacy {
@@ -300,6 +337,25 @@ fn convert(args: &[String]) -> Result<(), String> {
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
out.insert("importWarnings".into(), w.clone());
}
if preserve_editing
&& vectorcraft_engine::cmd::fileio::format_for_name(&output).is_some_and(|f| f.id == "pdf")
&& let Some(obj) = r.as_object_mut()
{
// The export leaves the attachment out (with this warning) for --artboard or a --range
// without every artboard; otherwise it writes it. Read that from the result, not the file.
let skipped = vectorcraft_engine::cmd::fileio::pdf::EDITING_NEEDS_EVERY_ARTBOARD;
let embedded = !obj.get("warnings").and_then(Value::as_array).is_some_and(|w| w.iter().any(|w| w.as_str() == Some(skipped)));
if embedded {
let note = "The PDF embeds the native VectorCraft document as an attachment (preserveEditing).";
if let Some(Value::Array(w)) = obj.get_mut("warnings") {
if !w.iter().any(|w| w.as_str() == Some(note)) {
w.push(json!(note));
}
} else {
obj.insert("warnings".into(), json!([note]));
}
}
}
outln!("{r}");
Ok(())
}
@@ -422,6 +478,10 @@ fn command_step(v: Value) -> Result<Step, String> {
}
fn run(args: &[String]) -> Result<(), String> {
let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s"];
if help_requested(args, &valued) {
return help();
}
let mut input: Option<String> = None;
let mut steps: Vec<Step> = vec![];
// The step of the `--cmd` just given, which a `--params` sets.
@@ -522,6 +582,9 @@ fn run(args: &[String]) -> Result<(), String> {
}
fn bench(args: &[String]) -> Result<(), String> {
if help_requested(args, &["--size", "--iters"]) {
return help();
}
let mut file = None;
let (mut w, mut h, mut iters) = (2880u32, 1800u32, 5u32);
let mut it = args.iter();
+3
View File
@@ -341,6 +341,9 @@ fn deep(n: usize) -> Result<(), String> {
}
pub fn run(args: &[String]) -> Result<(), String> {
if crate::help_requested(args, &["--paths"]) {
return crate::help();
}
let mut n = 50_000usize;
let mut want_deep = false;
let mut it = args.iter();
+62 -1
View File
@@ -201,6 +201,64 @@ fn run_refuses_malformed_commands_naming_the_step() {
assert_eq!(printed, 2);
}
/// `--help` and `-h` right after a subcommand print the usage that `vectorcraft-cli help` prints.
#[test]
fn help_after_a_subcommand_prints_the_usage() {
let usage = Command::new(BIN).arg("help").output().unwrap();
assert!(usage.status.success() && String::from_utf8_lossy(&usage.stdout).contains("USAGE:"));
for sub in ["mcp", "run", "commands", "convert", "info", "bench", "perf"] {
for flag in ["--help", "-h"] {
let out = Command::new(BIN).args([sub, flag]).stdin(Stdio::null()).output().unwrap();
assert!(out.status.success(), "{sub} {flag}: {}", String::from_utf8_lossy(&out.stderr));
assert!(out.stdout == usage.stdout, "{sub} {flag}: {}", String::from_utf8_lossy(&out.stdout));
}
}
}
/// `--help` and `-h` after other arguments print the usage too, before the subcommand does anything
/// with them.
#[test]
fn help_later_in_the_line_prints_the_usage() {
let usage = Command::new(BIN).arg("help").output().unwrap();
let mut wrong = vec![];
for args in [
&["mcp", "--headless", "--help"][..],
&["mcp", "--headless", "-h"],
&["mcp", "--connect", "127.0.0.1:1", "--help"],
&["run", "--cmd", "file.new", "--help"],
&["run", "--in", "missing.svg", "-h"],
&["run", "--commands", "missing.json", "--help"],
&["run", "--cmd", "file.new", "--params", "not json", "-h"],
&["commands", "x", "-h"],
&["convert", "missing.svg", "out.png", "--help"],
&["convert", "missing.svg", "--scale", "2", "-h"],
&["info", "missing.svg", "--help"],
&["bench", "missing.svg", "-h"],
&["bench", "--size", "10x10", "--help"],
&["bench", "--size", "bad", "-h"],
&["perf", "--paths", "10", "--help"],
&["perf", "--paths", "many", "-h"],
&["convert", "--decimals", "many", "--help"],
] {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
if !out.status.success() || out.stdout != usage.stdout {
wrong.push(format!("{args:?}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout)));
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
/// Further along the line, `-h` is the value of the option before it.
#[test]
fn help_as_an_option_value_goes_to_the_option() {
let out = Command::new(BIN).args(["run", "--cmd", "-h"]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success() && err.contains("vectorcraft-cli: step 0 (-h):") && out.stdout.is_empty(), "{err}");
let out = Command::new(BIN).args(["convert", "in.svg", "--range", "-h"]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success() && err.contains("convert needs IN and OUT") && out.stdout.is_empty(), "{err}");
}
#[test]
fn mcp_headless_over_stdio() {
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@@ -319,7 +377,10 @@ fn automation_roots(base: &std::path::Path) {
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-write-root"], "needs a folder");
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
fails(
&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))],
"a running app enforces its own roots: start it with `vectorcraft --control <port>",
);
let mut child = Command::new(BIN)
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))])
+23
View File
@@ -46,6 +46,29 @@ fn info(file: &str) -> Value {
serde_json::from_str(&ok(&["info", file])).unwrap()
}
/// #3627: `convert` to PDF omits the embedded native document unless asked.
#[test]
fn convert_pdf_omits_editing_data_unless_preserve_editing() {
let pdf = tmp("flyer.pdf");
make(&pdf, 120, 80);
let plain = tmp("plain.pdf");
ok(&["convert", &pdf, &plain]);
let bytes = std::fs::read(&plain).unwrap();
assert!(!bytes.windows(b"vectorcraft-editing.vectorcraft".len()).any(|w| w == b"vectorcraft-editing.vectorcraft"));
let kept = tmp("kept.pdf");
let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &kept, "--preserve-editing"])).unwrap();
let bytes = std::fs::read(&kept).unwrap();
assert!(bytes.windows(b"vectorcraft-editing.vectorcraft".len()).any(|w| w == b"vectorcraft-editing.vectorcraft"), "{r}");
assert!(r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|s| s.contains("preserveEditing")))), "{r}");
// A --range without every artboard leaves the attachment out: no "embeds" note, even when the
// output's own name contains the attachment's name.
let part = tmp("vectorcraft-editing.vectorcraft.pdf");
let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &part, "--preserve-editing", "--range", "2"])).unwrap();
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|s| s.contains("every artboard"))), "{r}");
assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|s| s.contains("preserveEditing"))), "{r}");
}
#[test]
fn convert_pdf_to_svg_and_info_on_ai() {
let pdf = tmp("in.pdf");
+1 -1
View File
@@ -29,4 +29,4 @@ wasm-bindgen = "0.2"
serde_json = { workspace = true }
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlInputElement", "KeyboardEvent", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+118
View File
@@ -0,0 +1,118 @@
//! Shortcuts the browser would also act on. Many of the app's Cmd and Ctrl chords are the
//! browser's too: the View menu's `Cmd+=`, `Cmd+-` and `Cmd+0` zoom the page, Edit's `Cmd+Z` and
//! `Cmd+Shift+Z` undo and redo in the browser (in Safari, Undo Close Tab when the page has nothing
//! to undo), `Cmd+[` and `Cmd+]` go back and forward, `Cmd+F` finds. eframe lets them through, so
//! the browser acted on them as well. For a press the keymap in effect binds (the user's shortcuts
//! included), the page's default action is cancelled; the key still reaches egui, which runs the
//! command (or a text field its own undo). A few chords never reach a page at all (Safari's
//! `Cmd+L`, Chrome's `Cmd+T`, `Cmd+W` and `Cmd+N`): the browser keeps those.
use egui::{Key, Modifiers};
/// Whether the browser's own action for a key press is cancelled. `key` is the event's `key` (the
/// character typed), `code` its `code` (the key's position), `modifiers` as eframe reports them,
/// and `alt_graph` whether AltGr is held.
///
/// Only Cmd and Ctrl presses: a plain or Shift press types (cancelling it would drop the
/// character it types), and so does AltGr, which Windows reports as Ctrl and Alt held. Cut, Copy
/// and Paste (with any other modifiers) are left to the browser, whose clipboard events carry
/// their text, and Reload too, as eframe leaves them.
pub(crate) fn kept_from_browser(key: &str, code: &str, modifiers: Modifiers, alt_graph: bool) -> bool {
if !(modifiers.ctrl || modifiers.mac_cmd) || alt_graph {
return false;
}
if matches!(Key::from_name(key).or(Key::from_name(code)), Some(Key::C | Key::V | Key::X | Key::R)) {
return false;
}
vectorcraft_ui_egui::shortcuts::runs_a_shortcut(key, code, modifiers)
}
/// The modifiers of a key event as eframe reports them to egui (its `modifiers_from_kb_event`).
#[cfg(target_arch = "wasm32")]
fn modifiers(e: &web_sys::KeyboardEvent) -> Modifiers {
Modifiers { alt: e.alt_key(), ctrl: e.ctrl_key(), shift: e.shift_key(), mac_cmd: e.meta_key(), command: e.ctrl_key() || e.meta_key() }
}
/// Whether the app has the keyboard for this event: it went to the canvas, or to the text input
/// eframe places right after it while a text field has the focus. Elsewhere (the page's body, a
/// file input) eframe ignores the key, so the browser keeps it.
#[cfg(target_arch = "wasm32")]
fn to_the_app(e: &web_sys::KeyboardEvent) -> bool {
use wasm_bindgen::JsCast as _;
let Some(target) = e.target() else { return false };
if target.has_type::<web_sys::HtmlCanvasElement>() {
return true;
}
target
.dyn_ref::<web_sys::HtmlInputElement>()
.and_then(|input| input.previous_element_sibling())
.is_some_and(|before| before.has_type::<web_sys::HtmlCanvasElement>())
}
/// Cancel the page's default action for [`kept_from_browser`] presses, before eframe's listener
/// sees the event (it doesn't stop it).
#[cfg(target_arch = "wasm32")]
pub(crate) fn keep_app_keys() {
use wasm_bindgen::JsCast as _;
let Some(window) = web_sys::window() else { return };
let on_key = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::KeyboardEvent)>::new(|e: web_sys::KeyboardEvent| {
// An IME's keys are its own until it commits.
if e.is_composing() || !to_the_app(&e) {
return;
}
if kept_from_browser(&e.key(), &e.code(), modifiers(&e), e.get_modifier_state("AltGraph")) {
e.prevent_default();
}
});
if let Err(e) = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true) {
log::error!("couldn't follow keydown events: {e:?}");
}
// The listener lives as long as the page.
on_key.forget();
}
#[cfg(test)]
mod tests {
use super::*;
/// Cmd as egui reports it on this platform: the Command key on macOS, Ctrl elsewhere.
fn cmd() -> Modifiers {
if cfg!(target_os = "macos") { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND }
}
#[test]
fn the_keymaps_chords_are_kept_from_the_browser() {
for (key, code, mods) in [
("=", "Equal", cmd()),
("-", "Minus", cmd()),
("0", "Digit0", cmd()),
("z", "KeyZ", cmd()),
("Z", "KeyZ", cmd() | Modifiers::SHIFT),
("[", "BracketLeft", cmd()),
] {
assert!(kept_from_browser(key, code, mods, false), "{key} ({code}) {mods:?}");
}
}
#[test]
fn typing_the_clipboard_and_reload_stay_with_the_browser() {
for (key, code, mods) in [
// Typing: plain and Shift presses.
("z", "KeyZ", Modifiers::NONE),
("Z", "KeyZ", Modifiers::SHIFT),
("=", "Equal", Modifiers::NONE),
// The clipboard's events carry the text; Paste and Match Style is Shift+Cmd+V.
("c", "KeyC", cmd()),
("x", "KeyX", cmd()),
("v", "KeyV", cmd()),
("V", "KeyV", cmd() | Modifiers::SHIFT),
("r", "KeyR", cmd()),
] {
assert!(!kept_from_browser(key, code, mods, false), "{key} ({code}) {mods:?}");
}
// AltGr+Z types ż on a Polish layout, reported as Ctrl and Alt held on Windows.
let ctrl_alt = Modifiers::CTRL | Modifiers::COMMAND | Modifiers::ALT;
assert!(!kept_from_browser("ż", "KeyZ", ctrl_alt, true));
assert!(!kept_from_browser("}", "Digit0", ctrl_alt, true));
}
}
+6 -1
View File
@@ -11,7 +11,10 @@
//! - Save / Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
//! hover, palm rejection, fingers navigate once a pen has been seen).
//! hover, palm rejection, fingers navigate once a pen has been seen, the pen's eraser end
//! selects the Eraser tool);
//! - a Cmd or Ctrl chord the keymap binds runs its command, not the browser's own action as well
//! (`Cmd+=` zooms the canvas, not the page; `Cmd+Z` undoes in the document) (`keys`).
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
@@ -20,6 +23,8 @@
mod api;
#[cfg(target_arch = "wasm32")]
mod fonts;
#[cfg(any(target_arch = "wasm32", test))]
mod keys;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
+129 -3
View File
@@ -5,6 +5,9 @@
//! - A pen drives egui's pointer and is a touch contact with the pen's pressure as its force (the
//! canvas reads it), so holding it still opens the context menu (egui's long touch). It hovers
//! where the pen can (Apple Pencil hover).
//! - A press of the pen's eraser end (the eraser button, `button` 5 in Pointer Events) presses
//! like the tip, and the pen's samples go into a [`StylusFeed`] with that end, so the canvas
//! selects the Eraser tool for the press. A press of the tip turns the pen back.
//! - Fingers are touch contacts: two pinch and pan the canvas, and tap to undo or redo. The first
//! finger also drives the pointer, so it taps the panels and menus; on the canvas it navigates
//! only once a pen has been seen ([`touch::set_source`]), for this page session only.
@@ -20,6 +23,7 @@ use std::cell::RefCell;
use std::rc::Rc;
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
use vectorcraft_ui_egui::touch::{self, Source};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast as _;
@@ -28,8 +32,11 @@ use wasm_bindgen::JsCast as _;
const PEN_DEVICE: TouchDeviceId = TouchDeviceId(1);
const FINGER_DEVICE: TouchDeviceId = TouchDeviceId(2);
/// The Pointer Events `button` of a pen's eraser button, which is how a pen's eraser end presses
/// (<https://www.w3.org/TR/pointerevents3/#the-button-property>).
const ERASER_BUTTON: i16 = 5;
/// The pen and finger contacts followed, and the egui events they made since the last frame.
#[derive(Default)]
pub struct Pointers {
events: Vec<Event>,
/// The pen pressing, by pointer id.
@@ -39,6 +46,16 @@ pub struct Pointers {
/// The contact driving egui's pointer.
primary: Option<i32>,
button: Option<PointerButton>,
/// The pen last pressed with its eraser end.
eraser: bool,
/// The pen's pressure and end for the canvas (`Services::stylus`).
stylus: StylusFeed,
}
impl Default for Pointers {
fn default() -> Self {
Self { events: vec![], pen: None, fingers: vec![], primary: None, button: None, eraser: false, stylus: StylusFeed::on_ui_thread() }
}
}
#[cfg(target_arch = "wasm32")]
@@ -71,6 +88,16 @@ impl Pointers {
std::mem::take(&mut self.events)
}
/// The feed the pen's samples go into, for `Services::stylus`.
pub fn stylus(&self) -> StylusFeed {
self.stylus.clone()
}
/// A pen moved or pressed with `pressure`, with the end it last pressed with.
fn pen_sample(&self, pressure: f32) {
self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser }));
}
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
self.events.push(Event::Touch { device_id: device, id: TouchId::from(id as u64), phase, pos, force });
}
@@ -110,6 +137,11 @@ impl Pointers {
/// Follow one pointer event; `true` if it was the pen's or a finger's (taken from eframe).
fn feed(&mut self, kind: &str, e: &Input<'_>, pos: Pos2, coalesced: &[(Pos2, f32)], on_canvas: bool) -> bool {
let id = e.id;
if e.pointer_type != "pen" && self.pen.is_none() && matches!(kind, "pointerdown" | "pointermove") {
// Another pointer moved while no pen presses (a palm under a pen pressing is
// rejected): the canvas takes no pen pressure or pen end for it.
self.stylus.set(None);
}
match e.pointer_type {
"pen" => {
let force = Some(e.pressure.clamp(0.0, 1.0));
@@ -118,14 +150,27 @@ impl Pointers {
if !on_canvas && self.pen.is_none() {
return false;
}
let Some(button) = button(e.button) else { return false };
// The eraser end presses like the tip; the tip and the eraser end each
// set the end the pen is turned to.
let (button, end) = match e.button {
ERASER_BUTTON => (PointerButton::Primary, Some(true)),
number => match button(number) {
Some(button) => (button, (number == 0).then_some(false)),
None => return false,
},
};
touch::set_source(Source::Pen);
self.cancel_fingers(pos, e.modifiers);
if let Some(end) = end {
self.eraser = end;
}
self.pen_sample(e.pressure);
self.pen = Some(id);
self.touch(PEN_DEVICE, id, TouchPhase::Start, pos, force);
self.press(id, pos, button, e.modifiers);
}
"pointermove" if self.pen == Some(id) => {
self.pen_sample(e.pressure);
// The positions the pen reported between two events (up to 240 a second
// on an iPad), each with its pressure, so fast strokes keep their curve.
for c in coalesced.iter().take(coalesced.len().saturating_sub(1)) {
@@ -143,10 +188,14 @@ impl Pointers {
if !on_canvas {
return false;
}
// Pointer Events report no end while a pen hovers: it keeps the end it
// last pressed with.
self.pen_sample(e.pressure);
self.events.push(Event::PointerMoved(pos));
}
"pointerup" | "pointercancel" if self.pen == Some(id) => {
let phase = if kind == "pointerup" { TouchPhase::End } else { TouchPhase::Cancel };
self.pen_sample(0.0);
self.pen = None;
self.touch(PEN_DEVICE, id, phase, pos, None);
self.release(pos, e.modifiers);
@@ -388,8 +437,85 @@ mod tests {
fn unknown_pen_buttons_are_not_reinterpreted_as_primary() {
reset();
let mut p = Pointers::default();
assert!(!p.feed("pointerdown", &input("pen", 5), Pos2::ZERO, &[], true));
assert!(!p.feed("pointerdown", &input("pen", 6), Pos2::ZERO, &[], true));
assert!(p.take().is_empty());
assert!(!touch::pen_seen());
assert_eq!(p.stylus().eraser(), None);
}
fn presses(p: &mut Pointers) -> Vec<(PointerButton, bool)> {
p.take()
.into_iter()
.filter_map(|e| match e {
Event::PointerButton { button, pressed, .. } => Some((button, pressed)),
_ => None,
})
.collect()
}
/// The eraser end presses as Pointer Events' eraser button (`button` 5): a primary press whose
/// samples carry the eraser end, kept while the pen hovers, until the tip presses.
#[test]
fn the_eraser_button_presses_with_the_eraser_end() {
reset();
let mut p = Pointers::default();
let feed = p.stylus();
let pos = Pos2::new(30.0, 40.0);
let eraser = input("pen", ERASER_BUTTON);
assert!(p.feed("pointerdown", &eraser, pos, &[], true));
assert_eq!(presses(&mut p), [(PointerButton::Primary, true)]);
assert!(touch::pen_seen());
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.6)));
assert!(p.feed("pointermove", &input("pen", -1), pos, &[], true));
assert_eq!(feed.eraser(), Some(true));
assert!(p.feed("pointerup", &eraser, pos, &[], true));
assert_eq!(presses(&mut p), [(PointerButton::Primary, false)]);
let mut hover = input("pen", -1);
hover.pressure = 0.0;
assert!(p.feed("pointermove", &hover, pos, &[], true));
assert_eq!(feed.eraser(), Some(true), "a hovering pen keeps the end it pressed with");
// The barrel button leaves the end as it was.
assert!(p.feed("pointerdown", &input("pen", 2), pos, &[], true));
assert!(p.feed("pointerup", &input("pen", 2), pos, &[], true));
assert_eq!(feed.eraser(), Some(true));
assert!(p.feed("pointerdown", &input("pen", 0), pos, &[], true));
assert_eq!(feed.eraser(), Some(false), "the tip");
reset();
}
/// A mouse or a finger that moves takes the pen out of the feed, so the canvas gives it no
/// pen pressure and does not switch tools for it.
#[test]
fn other_pointers_clear_the_pen_feed() {
for (pointer_type, kind) in [("mouse", "pointermove"), ("mouse", "pointerdown"), ("touch", "pointerdown"), ("touch", "pointermove")] {
reset();
let mut p = Pointers::default();
let feed = p.stylus();
assert!(p.feed("pointerdown", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true));
assert!(p.feed("pointerup", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true));
let mut other = input(pointer_type, 0);
other.id = 10;
p.feed(kind, &other, Pos2::ZERO, &[], true);
assert_eq!((feed.eraser(), feed.held()), (None, None), "{pointer_type} {kind}");
}
reset();
}
/// A palm that lands or moves while the eraser end presses is rejected, and the feed keeps the
/// eraser end for the canvas's next frame.
#[test]
fn a_rejected_palm_keeps_the_pen_end() {
reset();
let mut p = Pointers::default();
let feed = p.stylus();
assert!(p.feed("pointerdown", &input("pen", ERASER_BUTTON), Pos2::ZERO, &[], true));
let mut palm = input("touch", 0);
palm.id = 10;
for (pointer_type, kind) in [("touch", "pointerdown"), ("touch", "pointermove"), ("mouse", "pointermove")] {
palm.pointer_type = pointer_type;
p.feed(kind, &palm, Pos2::ZERO, &[], true);
assert_eq!(feed.eraser(), Some(true), "{pointer_type} {kind}");
}
reset();
}
}
+48 -34
View File
@@ -13,6 +13,7 @@ use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::stylus::StylusFeed;
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
@@ -81,6 +82,7 @@ pub fn start() {
restarts: 0,
};
track_paste(&host.pasted);
crate::keys::keep_app_keys();
run(host, canvas, None).await;
});
}
@@ -106,6 +108,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<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();
@@ -116,7 +119,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
host.pasted.borrow_mut().ctx = Some(ctx.clone());
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone());
let stylus = host.pointers.borrow().stylus();
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
let app = match taken.borrow_mut().take() {
Some(mut app) => {
// The old services asked the old context for frames.
@@ -365,55 +369,63 @@ impl eframe::App for WebShell {
self.restart(&why);
}
let Some(app) = &mut self.app else { return };
let pasted = self.host.pasted.borrow_mut().flavour.take();
if let Some((f, held)) = pasted {
app.paste_from_host(ctx, f, held);
}
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place(d) => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
}
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
}
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
app.logic(ctx);
app.tick(ctx);
crate::api::poll_replies();
app.finish_ime_logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
/// Forward host-gathered pointer events into this frame's raw input, then let the app apply its
/// shared input hook before egui runs.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
raw.events.extend(self.host.pointers.borrow_mut().take());
if let Some(app) = &mut self.app {
app.raw_input_hook(raw);
app.raw_input_hook(ctx, raw);
}
}
/// Read host paste and drop input once in the UI phase, hand files to the app's drop targets, run
/// the app UI, then poll control replies.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
if let Some(app) = &mut self.app {
// DOM paste and drop are host input: read them in the UI phase, once per pass, not in
// `logic` where a hidden tab replays the last shown frame's input.
let pasted = self.host.pasted.borrow_mut().flavour.take();
if let Some((f, held)) = pasted {
app.paste_from_host(&ctx, f, held);
}
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
let at = self.host.drag.take();
let z = ctx.zoom_factor();
let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place(d) => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
}
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
}
repaint.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
app.ui(ui);
}
crate::api::poll_replies();
}
}
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>) -> Services {
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>, stylus: StylusFeed) -> Services {
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
@@ -463,6 +475,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
recovery_store: Some(Arc::new(BrowserStore { locks })),
// File → Print: the browser's print dialog.
print: Some(Box::new(BrowserPrint)),
// The pen's end, from the page's Pointer Events (`crate::pencil`).
stylus: Some(stylus),
..Default::default()
}
}
+9 -6
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 }
@@ -43,23 +43,26 @@ winit = { version = "0.30", default-features = false }
craft-print = { workspace = true }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
clipboard-win = { workspace = true }
# Which GPU drives which display (EnumDisplayDevices, src/gpu.rs), so the window is drawn on the
# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over
# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter.
winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true }
# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as
# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit.
# DirectWrite's system font collection and GDI's font list (src/system_fonts.rs): fonts that font
# services such as Adobe Fonts load in place (#579, #1258). MIT OR Apache-2.0, already in the build
# through accesskit.
# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
# read a pen's barrel button from its pointer messages.
# read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has
# `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_WindowsAndMessaging"] }
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "macos")'.dependencies]
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
muda = { version = "=0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
+10 -2
View File
@@ -296,6 +296,14 @@ mod tests {
ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) }
}
/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a
/// busy machine either thread can pause for more than 150 ms between connecting and sending, or
/// between starting a reply and writing it, and a short budget then closes the connection with no
/// reply.
fn line_timeouts() -> ConnectionTimeouts {
ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() }
}
fn socket_fixture(
timeouts: ConnectionTimeouts,
handle: impl FnMut(String, Value) -> Option<Value> + Send + 'static,
@@ -344,7 +352,7 @@ mod tests {
/// A line that stalls is refused in words a client's author can act on, not the system's.
#[test]
fn stalled_control_line_is_refused_with_the_reason() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| panic!("incomplete requests must not execute"));
client.write_all(b"{\"method\":").unwrap();
let mut line = String::new();
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
@@ -419,7 +427,7 @@ mod tests {
#[test]
fn buffered_partial_control_line_also_expires() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| Some(json!({"ok": true})));
let (mut client, done, worker) = socket_fixture(line_timeouts(), |_, _| Some(json!({"ok": true})));
client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap();
let mut line = String::new();
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
+97 -38
View File
@@ -83,14 +83,34 @@ struct App {
maximize_tries: u8,
}
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
/// button's secondary click on Windows. The control channel's input and the native menu's keys
/// go to the main window only.
#[cfg(target_os = "macos")]
fn native_input_hook(app: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook)
}
#[cfg(all(windows, not(target_vendor = "win7")))]
fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
Some(std::sync::Arc::new(windows_stylus::raw_input_hook))
}
#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))]
fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
None
}
impl eframe::App for App {
// Each frame runs unconfined: it is the person at the keyboard's. The app confines what the
// control channel asks for, and the frames carrying input it injected, to the automation roots
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
file_access::unconfined(|| self.logic_frame(ctx, frame));
self.app.finish_ime_logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
/// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the
/// app's raw-input hook while the keyboard user is unconfined.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
@@ -103,7 +123,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) {
@@ -164,7 +184,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);
@@ -248,50 +268,57 @@ fn prefs_enabled() -> bool {
}
/// The saved UI preferences, read before the window opens (they hold its size and position).
/// A file read only in part, or with layouts converted from an older version, is copied beside
/// itself before anything saves over it.
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
if !prefs_enabled() {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
{
let read = vectorcraft_ui_egui::UiState::read_preferences(&bytes);
if read.as_ref().is_none_or(|read| !read.intact) {
keep_prefs_copy(&p, &bytes, "ui.unreadable");
}
if read.as_ref().is_some_and(|read| read.converted) {
keep_prefs_copy(&p, &bytes, "ui.before-layout-migration");
}
return read.map(|read| read.ui);
}
let bytes = legacy_prefs_path().and_then(|p| std::fs::read(p).ok())?;
decode_saved_prefs(&bytes)
}
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
/// Saved preferences, with what this version cannot read of their docking data dropped and
/// older layouts converted (`vectorcraft_ui_egui::layout_migration`).
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
use serde_json::{Map, Value, json};
vectorcraft_ui_egui::UiState::read_preferences(bytes).map(|read| read.ui)
}
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
if let Some(layout) = object.get("docking")
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
{
object.remove("docking");
}
if let Some(hidden) = object.get_mut(hidden_key) {
if let Value::Object(entries) = hidden {
entries.retain(|panel, location| {
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
});
} else {
object.remove(hidden_key);
}
}
}
/// Set when `ui.json` needed a copy kept ([`keep_prefs_copy`]) and none could be: the file is
/// then left as it is rather than overwritten.
static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
// Saved workspaces and the layout changes kept for each workspace carry the same layouts.
for key in ["custom_workspaces", "workspace_changes"] {
if let Some(Value::Array(workspaces)) = object.get_mut(key) {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
/// Copy `ui.json` beside it as `<stem>.json` or the first free `<stem>-N.json`, before the next
/// save replaces it: `ui.unreadable` when it could not be read in full (a newer VectorCraft may
/// have written it), `ui.before-layout-migration` when its layouts were converted.
fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) {
use std::io::Write as _;
for n in 1..100u32 {
let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") };
match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) {
Ok(mut file) => {
if file.write_all(bytes).is_ok() {
return;
}
break;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(_) => break,
}
}
serde_json::from_value(value).ok()
KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed);
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
@@ -305,7 +332,7 @@ fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiSta
}
fn save_prefs(app: &VectorcraftApp) {
if !prefs_enabled() {
if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
if let Some(p) = prefs_path() {
@@ -589,6 +616,9 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
// macOS reads it from AppKit's events (#852).
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
// Windows reads it from the pen's pointer messages (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
stylus: Some(windows_stylus::start()),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
@@ -788,6 +818,11 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
";
fn main() -> std::process::ExitCode {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::VECTORCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
#[cfg(target_os = "linux")]
cursor_theme::apply();
@@ -944,9 +979,9 @@ fn main() -> std::process::ExitCode {
}
options
};
// With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message
// hook notes each pointer contact and whether it starts with the barrel button held
// (`windows_stylus`).
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
// is down; the message hook notes each pointer contact and whether it starts with the barrel
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
let options = {
use winit::platform::windows::EventLoopBuilderExtWindows as _;
@@ -1014,11 +1049,13 @@ fn main() -> std::process::ExitCode {
run(
options,
Box::new(move |cc| {
VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
let mut session = Session::new();
// The profiles loaded before come back before the preferences name any of them.
prefs_dir::attach_profile_store(&mut session);
prefs_dir::attach_plugin_store(&mut session);
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
app.set_startup_update_check(true);
load_prefs(&mut app, saved);
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
// which takes over from the copy the preferences file used to hold.
@@ -1033,13 +1070,15 @@ fn main() -> std::process::ExitCode {
}
// Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
// User Defined swatch, graphic style and symbol libraries live next to the preferences.
// User Defined swatch, graphic style, symbol and brush libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string()));
app.session.symbol_libraries.set_user_dir(symbols);
let brushes = prefs_path().and_then(|p| Some(p.parent()?.join("Brushes").to_string_lossy().to_string()));
app.session.brush_libraries.set_user_dir(brushes);
// With the User Defined libraries listed, the persistent library panels reopen.
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
// So do the Libraries panel's libraries; runs without preferences (agents' test
@@ -1109,6 +1148,7 @@ fn main() -> std::process::ExitCode {
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
app.services.native_input_hook = native_input_hook(&app);
file_access::unconfined(|| open_files(&mut app, files));
// The first frame is due from now (`gpu::watch_first_frame`).
#[cfg(feature = "wgpu")]
@@ -1242,6 +1282,25 @@ mod tests {
);
}
#[test]
fn an_unreadable_ui_json_is_kept_beside_it() {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-prefs-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let path = dir.join("ui.json");
super::keep_prefs_copy(&path, b"{\"docking\": {\"root\": {\"FutureNode\": {}}}}", "ui.unreadable");
super::keep_prefs_copy(&path, b"not json", "ui.unreadable");
super::keep_prefs_copy(&path, b"{}", "ui.before-layout-migration");
let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap();
let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap();
let before = std::fs::read(dir.join("ui.before-layout-migration.json")).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced");
assert_eq!(second, b"not json");
assert_eq!(before, b"{}");
assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept");
}
#[test]
fn the_image_trace_preview_setting_is_kept_across_a_restart() {
for preview in [false, true] {
+239 -21
View File
@@ -1,39 +1,56 @@
//! Windows: the font files in DirectWrite's system font collection, scanned with the font folders
//! (`vectorcraft_text::set_platform_font_files`). The desktop app and `vectorcraft-cli` (exports,
//! MCP) both install it; the CLI shares this file.
//! Windows: the font files in DirectWrite's system font collection and those of the fonts GDI has
//! loaded, scanned with the font folders (`vectorcraft_text::set_platform_font_files`). The desktop
//! app and `vectorcraft-cli` (exports, MCP) both install it; the CLI shares this file.
//!
//! Font services such as Adobe Fonts load their fonts with GDI at run time, from files in their
//! own folders, without installing them: the font folders and the registry don't list them. Since
//! Windows 10 (version 1607) DirectWrite's system font collection holds them too
//! Windows 10 (version 1607) DirectWrite's system font collection is meant to hold them too
//! (<https://learn.microsoft.com/windows/win32/directwrite/what-s-new-in-directwrite-for-windows-8-consumer-preview>,
//! "Support for Adobe Typekit and other font-service clients"), so VectorCraft lists them as other
//! apps do, reading the files in place (#579).
//! apps do, reading the files in place (#579). It doesn't always: on some machines the fonts
//! Creative Cloud loads are in GDI's font list (Word and the font dialogs show them) but not in
//! the collection (#1258). So the families GDI lists that the collection doesn't name are asked
//! for one by one: a GDI font selected into a device context gives the DirectWrite font face
//! GDI uses (`IDWriteGdiInterop::CreateFontFaceFromHdc`), and that face its files.
//!
//! DirectWrite is COM, which has no safe API, hence the scoped `unsafe_code` allowance.
//! DirectWrite is COM and GDI a C API, neither with a safe API, hence the scoped `unsafe_code`
//! allowance.
#![allow(unsafe_code)]
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Mutex;
use windows::Win32::Foundation::LPARAM;
use windows::Win32::Graphics::DirectWrite::{
DWRITE_FACTORY_TYPE_SHARED, DWriteCreateFactory, IDWriteFactory3, IDWriteFontSet, IDWriteLocalFontFileLoader,
DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_PROPERTY_ID_WIN32_FAMILY_NAME, DWriteCreateFactory, IDWriteFactory3, IDWriteFontFile, IDWriteFontSet,
IDWriteGdiInterop, IDWriteLocalFontFileLoader, IDWriteStringList,
};
use windows::Win32::Graphics::Gdi::{
CreateCompatibleDC, CreateFontIndirectW, DEFAULT_CHARSET, DeleteDC, DeleteObject, EnumFontFamiliesExW, HDC, HGDIOBJ, LOGFONTW, RASTER_FONTTYPE,
SelectObject, TEXTMETRICW,
};
use windows::core::Interface;
/// The most fonts read from the collection (a system has a few thousand).
const MAX_FONTS: u32 = 1 << 16;
/// The most families and faces taken from GDI's font list.
const MAX_GDI_FONTS: usize = 1 << 16;
/// The most files one font face is made of (one, but for a few Type 1 fonts).
const MAX_FACE_FILES: u32 = 8;
/// The longest file path taken, in UTF-16 units (Windows' own limit).
const MAX_PATH: u32 = 32_767;
/// Have the font scans read the font files DirectWrite lists.
/// Have the font scans read the font files DirectWrite and GDI list.
pub fn install() {
vectorcraft_text::set_platform_font_files(font_files);
}
/// The local files of the fonts in DirectWrite's system font collection as it is now (fonts
/// loaded since the last call included). None before Windows 10, or when DirectWrite fails: the
/// font folders and the registry still list the installed fonts.
/// loaded since the last call included), with those of the fonts GDI lists that the collection
/// leaves out. None before Windows 10, or when DirectWrite fails: the font folders and the
/// registry still list the installed fonts.
fn font_files() -> Vec<String> {
match collection_files() {
match listed_files() {
Ok(files) => files.into_iter().collect(),
Err(e) => {
log::debug!("DirectWrite's system fonts aren't listed: {e}");
@@ -42,30 +59,48 @@ fn font_files() -> Vec<String> {
}
}
fn collection_files() -> windows::core::Result<BTreeSet<String>> {
fn listed_files() -> windows::core::Result<BTreeSet<String>> {
// SAFETY: plain DirectWrite calls on interfaces it returned, each checked for failure; the
// collection is asked to check for fonts loaded since the factory last looked.
let set = unsafe {
let (factory, set) = unsafe {
let factory: IDWriteFactory3 = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED)?;
let mut collection = None;
factory.GetSystemFontCollection(false, &mut collection, true)?;
let Some(collection) = collection else { return Ok(BTreeSet::new()) };
collection.GetFontSet()?
(factory, collection.map(|c| c.GetFontSet()).transpose()?)
};
let mut files = set.as_ref().map(collection_files).unwrap_or_default();
// GDI's own names for the collection's families, the names GDI lists them by.
// SAFETY: a list DirectWrite returns, read by `strings`.
let named = set.as_ref().and_then(|s| unsafe { s.GetPropertyValues(DWRITE_FONT_PROPERTY_ID_WIN32_FAMILY_NAME) }.ok());
let named: BTreeSet<String> = named.as_ref().map(strings).unwrap_or_default().iter().map(|n| n.to_lowercase()).collect();
match gdi_files(&factory, &named) {
Ok(more) => files.extend(more),
Err(e) => log::debug!("GDI's fonts aren't listed: {e}"),
}
Ok(files)
}
fn collection_files(set: &IDWriteFontSet) -> BTreeSet<String> {
// SAFETY: a count of the set's fonts.
let count = unsafe { set.GetFontCount() }.min(MAX_FONTS);
// Faces of one file (a collection's, a variable font's instances) name it once.
Ok((0..count).filter_map(|i| local_path(&set, i)).collect())
(0..count).filter_map(|i| local_path(set, i)).collect()
}
/// The path of the file holding font `i` of `set`, when it is a local file (not a downloadable
/// font, nor one in memory).
fn local_path(set: &IDWriteFontSet, i: u32) -> Option<String> {
// SAFETY: `i` is below the set's count; the key DirectWrite hands back stays valid while
// `file` (which owns it) lives, and is passed back with its own size; the path buffer is
// sized from DirectWrite's answer plus the terminating NUL.
// SAFETY: `i` is below the set's count.
let file = unsafe { set.GetFontFaceReference(i).ok()?.GetFontFile().ok()? };
file_path(&file)
}
/// The path of `file`, when it is a local file.
fn file_path(file: &IDWriteFontFile) -> Option<String> {
// SAFETY: the key DirectWrite hands back stays valid while `file` (which owns it) lives, and is
// passed back with its own size; the path buffer is sized from DirectWrite's answer plus the
// terminating NUL.
unsafe {
let file = set.GetFontFaceReference(i).ok()?.GetFontFile().ok()?;
let loader: IDWriteLocalFontFileLoader = file.GetLoader().ok()?.cast().ok()?;
let (mut key, mut size) = (std::ptr::null_mut(), 0u32);
file.GetReferenceKey(&mut key, &mut size).ok()?;
@@ -79,6 +114,159 @@ fn local_path(set: &IDWriteFontSet, i: u32) -> Option<String> {
}
}
/// The strings of `list`.
fn strings(list: &IDWriteStringList) -> Vec<String> {
// SAFETY: indices below the list's count; each buffer is sized from DirectWrite's answer plus
// the terminating NUL.
let count = unsafe { list.GetCount() }.min(MAX_FONTS);
(0..count)
.filter_map(|i| unsafe {
let len = list.GetStringLength(i).ok()?;
if len > MAX_PATH {
return None;
}
let mut s = vec![0u16; len as usize + 1];
list.GetString(i, &mut s).ok()?;
Some(String::from_utf16_lossy(s.get(..len as usize)?))
})
.collect()
}
/// A memory device context, deleted when dropped.
struct MemoryDc(HDC);
impl MemoryDc {
fn new() -> windows::core::Result<Self> {
// SAFETY: a device context compatible with the screen, owned by the guard.
let dc = unsafe { CreateCompatibleDC(None) };
if dc.is_invalid() { Err(windows::core::Error::from_thread()) } else { Ok(Self(dc)) }
}
}
impl Drop for MemoryDc {
fn drop(&mut self) {
// SAFETY: the guard's own device context, with its first font back in it.
// Failing to delete it leaks a handle, which there's nothing to do about.
let _ = unsafe { DeleteDC(self.0) };
}
}
/// The files of the faces of the font families GDI lists that DirectWrite's collection doesn't
/// name (`named`, lower case).
fn gdi_files(factory: &IDWriteFactory3, named: &BTreeSet<String>) -> windows::core::Result<BTreeSet<String>> {
// SAFETY: a DirectWrite call checked for failure.
let interop = unsafe { factory.GetGdiInterop()? };
let dc = MemoryDc::new()?;
let mut families = BTreeSet::new();
enum_fonts(&dc, [0; 32], add_family, &mut families);
let mut faces = Vec::new();
for family in families.iter().filter(|f| !named.contains(&face_name(f).to_lowercase())) {
enum_fonts(&dc, *family, add_face, &mut faces);
}
// A face listed once for each of its character sets is read once.
faces.sort_by_key(face_key);
faces.dedup_by_key(|f| face_key(f));
// Asking for a face's files takes about a third of a millisecond, and the fonts are looked at
// every few seconds (#2576): the faces still listed keep the files they had while those exist.
// A face no longer listed is forgotten, so one activated again is asked for anew, and one
// whose files couldn't be read (GDI or DirectWrite failed, or it has no local file) is asked
// again at the next look.
let mut known = KNOWN_FACES.lock().unwrap_or_else(|e| e.into_inner());
let mut now = BTreeMap::new();
for f in &faces {
let key = face_key(f);
let files = match known.remove(&key) {
Some(files) if !files.is_empty() && files.iter().all(|p| std::path::Path::new(p).is_file()) => files,
_ => face_files(&interop, &dc, f),
};
now.insert(key, files);
}
*known = now;
Ok(known.values().flatten().cloned().collect())
}
/// A GDI face: its name (NUL-padded), weight and whether it's italic.
type FaceKey = ([u16; 32], i32, u8);
fn face_key(f: &LOGFONTW) -> FaceKey {
(f.lfFaceName, f.lfWeight, f.lfItalic)
}
/// The files of the faces GDI listed at the last look ([`gdi_files`]).
static KNOWN_FACES: Mutex<BTreeMap<FaceKey, Vec<String>>> = Mutex::new(BTreeMap::new());
/// Have GDI call `add` with `out` for each font of the family named `family` (NUL-padded), or for
/// one font of each family when it's empty.
fn enum_fonts<T>(
dc: &MemoryDc,
family: [u16; 32],
add: unsafe extern "system" fn(*const LOGFONTW, *const TEXTMETRICW, u32, LPARAM) -> i32,
out: &mut T,
) {
let query = LOGFONTW { lfCharSet: DEFAULT_CHARSET, lfFaceName: family, ..Default::default() };
// SAFETY: `add` reads the fonts GDI passes and casts the LPARAM back to the `T` it is, which
// outlives the call (GDI calls back before returning). Its answer is the last callback's.
let _ = unsafe { EnumFontFamiliesExW(dc.0, &query, Some(add), LPARAM(std::ptr::from_mut(out) as isize), 0) };
}
/// A GDI font list callback adding the font's family name to the `BTreeSet<[u16; 32]>` in `out`.
unsafe extern "system" fn add_family(font: *const LOGFONTW, _: *const TEXTMETRICW, kind: u32, out: LPARAM) -> i32 {
// SAFETY: GDI passes a valid font, and `out` is the set `enum_fonts` was given.
let (Some(font), Some(out)) = (unsafe { font.as_ref() }, unsafe { (out.0 as *mut BTreeSet<[u16; 32]>).as_mut() }) else {
return 0;
};
// Bitmap fonts have no file DirectWrite reads; `@` names are vertical writing's copies.
if kind & RASTER_FONTTYPE == 0 && font.lfFaceName.first().is_some_and(|&c| c != 0 && c != u16::from(b'@')) {
out.insert(font.lfFaceName);
}
i32::from(out.len() < MAX_GDI_FONTS)
}
/// A GDI font list callback adding the font to the `Vec<LOGFONTW>` in `out`.
unsafe extern "system" fn add_face(font: *const LOGFONTW, _: *const TEXTMETRICW, kind: u32, out: LPARAM) -> i32 {
// SAFETY: GDI passes a valid font, and `out` is the list `enum_fonts` was given.
let (Some(font), Some(out)) = (unsafe { font.as_ref() }, unsafe { (out.0 as *mut Vec<LOGFONTW>).as_mut() }) else {
return 0;
};
if kind & RASTER_FONTTYPE == 0 {
out.push(*font);
}
i32::from(out.len() < MAX_GDI_FONTS)
}
/// `name`, NUL-padded, as a string.
fn face_name(name: &[u16; 32]) -> String {
String::from_utf16_lossy(name.split(|&c| c == 0).next().unwrap_or_default())
}
/// The local files of the DirectWrite font face GDI uses for `font`.
fn face_files(interop: &IDWriteGdiInterop, dc: &MemoryDc, font: &LOGFONTW) -> Vec<String> {
// SAFETY: the font is selected into the guard's device context only while DirectWrite reads
// it, then the context's own font goes back and the font is deleted; the files array is
// sized from DirectWrite's count.
unsafe {
let gdi_font = CreateFontIndirectW(font);
if gdi_font.is_invalid() {
return Vec::new();
}
let first = SelectObject(dc.0, HGDIOBJ::from(gdi_font));
let face = interop.CreateFontFaceFromHdc(dc.0);
SelectObject(dc.0, first);
// Failing to delete it leaks a handle, which there's nothing to do about.
let _ = DeleteObject(HGDIOBJ::from(gdi_font));
let Ok(face) = face else { return Vec::new() };
let mut count = 0;
if face.GetFiles(&mut count, None).is_err() || count == 0 || count > MAX_FACE_FILES {
return Vec::new();
}
let mut files = vec![None; count as usize];
if face.GetFiles(&mut count, Some(files.as_mut_ptr())).is_err() {
return Vec::new();
}
files.iter().flatten().filter_map(file_path).collect()
}
}
#[cfg(test)]
mod tests {
/// DirectWrite lists the installed fonts, by their files' full paths.
@@ -89,4 +277,34 @@ mod tests {
let fonts = std::path::Path::new(&std::env::var("WINDIR").unwrap()).join("Fonts").to_string_lossy().to_lowercase();
assert!(files.iter().any(|f| f.to_lowercase().starts_with(&fonts)), "{} files, {:?}", files.len(), files.first());
}
/// A font loaded with GDI from a file without an extension outside the font folders, as
/// Creative Cloud loads Adobe Fonts, is listed by its file even though DirectWrite's collection
/// doesn't hold it (#1258): fonts loaded for one process never are.
#[test]
fn fonts_only_gdi_has_loaded_are_listed_by_their_files() {
use windows::Win32::Graphics::Gdi::{AddFontResourceExW, FR_PRIVATE, RemoveFontResourceExW};
let dir = std::env::temp_dir().join(format!("vc-gdi-fonts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("29457");
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf");
let mut data = std::fs::read(source).unwrap();
// Renamed (to a name as long) so no installed Source Sans 3 stands in for it.
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(utf16("Source Sans 3"), utf16("GdiOnly Sans3")), (b"Source Sans 3".to_vec(), b"GdiOnly Sans3".to_vec())] {
let mut i = 0;
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
data[i + at..i + at + to.len()].copy_from_slice(&to);
i += at + to.len();
}
}
std::fs::write(&file, data).unwrap();
let path = windows::core::HSTRING::from(file.as_os_str());
assert_eq!(unsafe { AddFontResourceExW(&path, FR_PRIVATE, None) }, 1);
let files = super::font_files();
let _ = unsafe { RemoveFontResourceExW(&path, FR_PRIVATE.0, None) };
let _ = std::fs::remove_dir_all(&dir);
let wanted = file.to_string_lossy().to_lowercase();
assert!(files.iter().any(|f| f.to_lowercase() == wanted), "{wanted} not in {} files", files.len());
}
}
+153 -2
View File
@@ -5,30 +5,74 @@
//! second-button flag ("analogous to a mouse right button down",
//! <https://learn.microsoft.com/en-us/windows/win32/inputmsg/wm-pointerdown>): [`msg_hook`] notes
//! it, and [`raw_input_hook`] makes the primary button of that contact the secondary one.
//!
//! winit also drops which end of the pen is down. For each pointer message of a pen, [`msg_hook`]
//! reads the pen's `POINTER_PEN_INFO` and writes its pressure and end into the canvas's pen feed
//! ([`start`]): `PEN_FLAG_INVERTED` ("The pen is inverted") or `PEN_FLAG_ERASER` ("The eraser
//! button is pressed") is the eraser end
//! (<https://learn.microsoft.com/en-us/windows/win32/inputmsg/pen-flags-constants>). Pointer
//! messages also come while the pen hovers, so turning the pen over selects the Eraser tool before
//! it touches. A message of another pointer, a pen leaving detection range or the window, or a
//! mouse message that Windows did not make from a pen or a touch
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
//! clears the feed.
#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))]
use std::cell::RefCell;
use std::sync::OnceLock;
use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's
/// `wParam`, a pen in contact with its barrel button pressed.
const POINTER_MESSAGE_FLAG_SECONDBUTTON: usize = 0x0020;
/// `POINTER_MESSAGE_FLAG_INRANGE` (`winuser.h`): not set on the message of a pointer that has left
/// detection range.
const POINTER_MESSAGE_FLAG_INRANGE: usize = 0x0002;
/// At most this many pointer contacts wait for their touch event.
const MAX_PENDING: usize = 16;
/// `PEN_FLAG_INVERTED` and `PEN_FLAG_ERASER` (`winuser.h`), in `POINTER_PEN_INFO::penFlags`.
const PEN_FLAG_INVERTED: u32 = 0x0002;
const PEN_FLAG_ERASER: u32 = 0x0004;
/// `PEN_MASK_PRESSURE` (`winuser.h`), in `POINTER_PEN_INFO::penMask`: the pen reports pressure.
const PEN_MASK_PRESSURE: u32 = 0x0001;
/// `MI_WP_SIGNATURE` and `SIGNATURE_MASK`: the extra information of a mouse message that Windows
/// made from a pen or a touch.
const MI_WP_SIGNATURE: u32 = 0xFF51_5700;
const SIGNATURE_MASK: u32 = 0xFFFF_FF00;
/// `WM_MOUSEFIRST` to `WM_MOUSELAST` (`winuser.h`).
const MOUSE_MESSAGES: std::ops::RangeInclusive<u32> = 0x0200..=0x020E;
/// `WM_POINTERUPDATE`, `WM_POINTERDOWN`, `WM_POINTERUP`, `WM_POINTERENTER` and `WM_POINTERLEAVE`
/// (`winuser.h`).
const WM_POINTERUPDATE: u32 = 0x0245;
const WM_POINTERDOWN: u32 = 0x0246;
const WM_POINTERUP: u32 = 0x0247;
const WM_POINTERENTER: u32 = 0x0249;
const WM_POINTERLEAVE: u32 = 0x024A;
thread_local! {
static STROKES: RefCell<BarrelStrokes> = RefCell::new(BarrelStrokes::default());
}
/// The pen feed [`msg_hook`] writes, once [`start`] has made it.
static FEED: OnceLock<StylusFeed> = OnceLock::new();
/// The canvas's pen feed, which [`msg_hook`] writes on the UI thread before winit handles each
/// message, so a press never waits for it.
pub fn start() -> StylusFeed {
FEED.get_or_init(StylusFeed::on_ui_thread).clone()
}
/// winit's message hook (`EventLoopBuilderExtWindows::with_msg_hook`), which winit calls for each
/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id
/// and whether the barrel button was held.
/// and whether the barrel button was held, and writes the pen's pressure and end into the feed.
/// It never consumes the message.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[allow(unsafe_code)]
pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
use windows::Win32::UI::WindowsAndMessaging::{MSG, WM_POINTERDOWN};
use windows::Win32::UI::Input::Pointer::{GetPointerPenInfo, POINTER_FLAG_INCONTACT, POINTER_PEN_INFO};
use windows::Win32::UI::WindowsAndMessaging::{GetMessageExtraInfo, MSG};
// SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
// call; `as_ref` gives `None` for a null pointer.
let Some(msg) = (unsafe { msg.cast::<MSG>().as_ref() }) else {
@@ -41,9 +85,63 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
}
});
}
let Some(feed) = FEED.get() else {
return false;
};
let read_pen = || {
let mut info = POINTER_PEN_INFO::default();
// SAFETY: `info` is a `POINTER_PEN_INFO` that lives for the call. The call fails for a
// pointer that is not a pen.
unsafe { GetPointerPenInfo((msg.wParam.0 & 0xffff) as u32, &mut info) }.ok()?;
let in_contact = info.pointerInfo.pointerFlags.0 & POINTER_FLAG_INCONTACT.0 != 0;
Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact))
};
// SAFETY: `GetMessageExtraInfo` reads the extra information of the thread's last message.
let from_mouse = || is_mouse(unsafe { GetMessageExtraInfo() }.0 as u32);
if let Some(sample) = feed_update(msg.message, msg.wParam.0, read_pen, from_mouse) {
feed.set_sample(sample);
}
false
}
/// What a pen whose `POINTER_PEN_INFO` holds `pressure` (0..1024), `pen_mask` and `pen_flags`
/// reports: the pressure as winit gives it to a touch, 0 while it hovers, a full press for a pen
/// in contact that reports none; and the eraser end while it is inverted or its eraser button is
/// pressed.
fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> PenSample {
let pressure = match pressure {
1..=1024 if pen_mask & PEN_MASK_PRESSURE != 0 => pressure as f32 / 1024.0,
_ if in_contact => 1.0,
_ => 0.0,
};
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 }
}
/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,
/// not from a pen or a touch.
fn is_mouse(extra_info: u32) -> bool {
extra_info & SIGNATURE_MASK != MI_WP_SIGNATURE
}
/// What the feed is set to for a message with this `wParam`: `Some(Some(sample))` for a pointer
/// message of a pen in range (`read_pen`), `Some(None)` for a pointer message of another pointer,
/// a pointer leaving detection range or the window, or a mouse message from a mouse
/// (`from_mouse`), and `None` to leave it as it is.
fn feed_update(
message: u32,
wparam: usize,
read_pen: impl FnOnce() -> Option<PenSample>,
from_mouse: impl FnOnce() -> bool,
) -> Option<Option<PenSample>> {
match message {
WM_POINTERENTER | WM_POINTERUPDATE | WM_POINTERDOWN | WM_POINTERUP if (wparam >> 16) & POINTER_MESSAGE_FLAG_INRANGE == 0 => Some(None),
WM_POINTERENTER | WM_POINTERUPDATE | WM_POINTERDOWN | WM_POINTERUP => Some(read_pen()),
WM_POINTERLEAVE => Some(None),
m if MOUSE_MESSAGES.contains(&m) && from_mouse() => Some(None),
_ => None,
}
}
/// Before egui reads a frame's input: the primary button of a contact that started with the
/// barrel button held becomes the secondary button.
pub fn raw_input_hook(raw: &mut egui::RawInput) {
@@ -287,6 +385,59 @@ mod tests {
assert_eq!(buttons(&rewrite(&mut strokes, &events)), [PRIMARY_DOWN]);
}
/// `PEN_FLAG_BARREL` (`winuser.h`).
const PEN_FLAG_BARREL: u32 = 0x0001;
#[test]
fn an_inverted_pen_or_its_eraser_button_is_the_eraser_end() {
let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true).eraser;
assert!(!eraser(0), "the tip");
assert!(!eraser(PEN_FLAG_BARREL), "the barrel button");
assert!(eraser(PEN_FLAG_INVERTED), "turned over");
assert!(eraser(PEN_FLAG_ERASER), "the eraser button");
assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_ERASER));
assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_BARREL));
}
#[test]
fn a_pens_pressure_is_the_one_winit_gives_its_touch() {
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5);
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0);
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), PenSample { pressure: 0.0, eraser: true }, "hovering");
assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one");
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0");
assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false).pressure, 0.0);
}
#[test]
fn mouse_messages_from_a_pen_or_a_touch_are_told_apart() {
assert!(is_mouse(0), "a mouse");
assert!(!is_mouse(0xFF51_5701), "a pen");
assert!(!is_mouse(0xFF51_5780), "a touch");
assert!(is_mouse(0xFF51_5600));
}
#[test]
fn pen_messages_set_the_feed_and_other_pointers_clear_it() {
let pen = PenSample { pressure: 0.0, eraser: true };
// `POINTER_MESSAGE_FLAG_INRANGE` in the high word of `wParam`, pointer 7 in the low word.
let in_range = (POINTER_MESSAGE_FLAG_INRANGE << 16) | 7;
let no_mouse = || -> bool { panic!("a pointer message is not a mouse message") };
let no_pen = || -> Option<PenSample> { panic!("no pen to read") };
for m in [WM_POINTERENTER, WM_POINTERUPDATE, WM_POINTERDOWN, WM_POINTERUP] {
assert_eq!(feed_update(m, in_range, || Some(pen), no_mouse), Some(Some(pen)), "{m:#x}");
assert_eq!(feed_update(m, in_range, || None, no_mouse), Some(None), "a touch: {m:#x}");
assert_eq!(feed_update(m, 7, no_pen, no_mouse), Some(None), "out of range: {m:#x}");
}
assert_eq!(feed_update(WM_POINTERLEAVE, in_range, no_pen, no_mouse), Some(None));
// WM_MOUSEMOVE and WM_LBUTTONDOWN.
assert_eq!(feed_update(0x0200, 0, no_pen, || true), Some(None), "a mouse moved");
assert_eq!(feed_update(0x0201, 0, no_pen, || false), None, "made from a pen");
// WM_KEYDOWN.
assert_eq!(feed_update(0x0100, 0, no_pen, || -> bool { panic!("not a mouse message") }), None);
}
#[test]
fn contacts_whose_touch_never_arrives_are_bounded() {
let mut strokes = BarrelStrokes::default();
+27 -11
View File
@@ -1,19 +1,22 @@
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
use std::io::Read;
use std::process::{Command, Output, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Runs the desktop binary without a display, so a regression fails instead of opening a window.
/// Runs the desktop binary without a display and with a control port it cannot parse, so a
/// regression exits with that error instead of opening a window.
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
let mut child = Command::new(BIN)
.args(args)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env("VECTORCRAFT_CONTROL_PORT", "not-a-port")
.env("VECTORCRAFT_NO_PREFS", "1")
.envs(env.iter().copied())
.stdin(Stdio::null())
@@ -21,15 +24,28 @@ fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while child.try_wait().unwrap().is_none() {
if Instant::now() >= deadline {
child.kill().unwrap();
panic!("{args:?}: the app started instead of printing its usage");
}
std::thread::sleep(Duration::from_millis(10));
}
child.wait_with_output().unwrap()
// Both pipes end when the binary exits, however long a loaded machine takes to start it. The
// deadline only stops a binary that is still running.
let deadline = Instant::now() + Duration::from_secs(120);
let stdout = read_to_end(child.stdout.take().unwrap());
let stderr = read_to_end(child.stderr.take().unwrap());
let recv = |rx: &mpsc::Receiver<Vec<u8>>| rx.recv_timeout(deadline.saturating_duration_since(Instant::now()));
let (Ok(stdout), Ok(stderr)) = (recv(&stdout), recv(&stderr)) else {
child.kill().unwrap();
panic!("{args:?}: the app started instead of printing its usage");
};
Output { status: child.wait().unwrap(), stdout, stderr }
}
/// Reads `pipe` to its end on another thread.
fn read_to_end(mut pipe: impl Read + Send + 'static) -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
let _ = tx.send(buf);
});
rx
}
fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
+10
View File
@@ -159,3 +159,13 @@ display_name = "Aditya Sisodiya"
real_name = "Angelo Bernardini"
display_name = "Angelo Bernardini"
sync_github_name = false
[people.DCaioP]
real_name = "Caio Pontes Magalhães"
display_name = "Caio Pontes"
sync_github_name = false
[people.xbirks]
real_name = "Andrés Ortega Montoya"
display_name = "Andrés Ortega Montoya"
sync_github_name = false
+1 -1
View File
@@ -119,7 +119,7 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
for r in &mut t.runs {
for p in [&mut r.style.fill, &mut r.style.stroke] {
if let Paint::Solid { color: c, .. } = p {
*c = c.lerp(color, amount);
*p = Paint::solid(c.lerp(color, amount));
}
}
}
+36 -1
View File
@@ -285,6 +285,13 @@ pub struct ArtBrush {
pub scale: ArtScale,
/// Width in percent.
pub width: f64,
/// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from
/// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest.
#[serde(skip_serializing_if = "Option::is_none")]
pub width_mode: Option<Variation>,
/// The width at the heaviest pen pressure (%), for Pressure (`width` without one).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_max: Option<f64>,
pub flip_along: bool,
pub flip_across: bool,
/// When true, art is placed on each run between sharp corners so folds do not overlap.
@@ -295,6 +302,15 @@ pub struct ArtBrush {
impl ArtBrush {
/// The range of the width (%).
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
/// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the
/// width follows the pressure.
pub fn width_range(&self) -> (f64, f64) {
match self.width_mode {
Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)),
_ => (self.width, self.width),
}
}
}
impl Default for ArtBrush {
@@ -304,6 +320,8 @@ impl Default for ArtBrush {
direction: Direction::LeftToRight,
scale: ArtScale::Stretch,
width: 100.0,
width_mode: None,
width_max: None,
flip_along: false,
flip_across: false,
overlap: false,
@@ -458,6 +476,18 @@ impl Brush {
}
}
/// [`Brush::art`], to change (relink the resources it uses).
pub fn art_mut(&mut self) -> Vec<&mut Node> {
match &mut self.kind {
BrushKind::Scatter(s) => vec![&mut s.art],
BrushKind::Art(a) => vec![&mut a.art],
BrushKind::Pattern(p) => std::iter::once(&mut p.side)
.chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten())
.collect(),
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
}
}
/// A brush definition from JSON (a stored library entry or a command's params), its values
/// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key`
/// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or
@@ -508,6 +538,11 @@ impl Brush {
}
BrushKind::Art(a) => {
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
// The width follows the pen's pressure or stays fixed.
if a.width_mode != Some(Variation::Pressure) {
a.width_mode = None;
}
a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width));
if let ArtScale::BetweenGuides { start, end } = &mut a.scale {
*start = within(*start, (0.0, 1.0), 0.0);
*end = within(*end, (0.0, 1.0), 1.0);
@@ -592,7 +627,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w),
BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
+78
View File
@@ -363,6 +363,12 @@ fn stain_mixes_type_strokes_too() {
let mut art = type_art(red.clone(), red);
tint_node(&mut art, &blue, 1.0);
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
// Characters painted with a global swatch lose the link, as a path's paint does: the mixed color
// is not the swatch's color.
let linked = Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 };
let mut art = type_art(linked.clone(), linked);
tint_node(&mut art, &blue, 1.0);
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
}
#[test]
@@ -579,6 +585,78 @@ fn scatter_values_follow_their_modes() {
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
/// Art Brush Options: with Width on Pressure the art goes from Width at the lightest pen pressure
/// to the second width at the heaviest, along the stroke where the pen pressed; Fixed keeps Width.
#[test]
fn art_brush_width_follows_the_pressure() {
let brush = |mode: Option<Variation>, max: Option<f64>| {
let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
if let BrushKind::Art(a) = &mut b.kind {
(a.width, a.width_mode, a.width_max) = (50.0, mode, max);
}
b
};
let bp = line(0.0, 0.0, 100.0, 0.0);
let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st);
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
// 4 pt art at 50 %: 1 pt either side at the lightest; at 300 %: 6 pt at the heaviest.
let out = draw(&brush(Some(Variation::Pressure), Some(300.0)), &ramp);
assert!(near(half_width(&out, 0.0, 1.0), 1.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
assert!(near(half_width(&out, 99.0, 100.0), 6.0, 0.1), "{}", half_width(&out, 99.0, 100.0));
assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0));
// The stroke weight scales it.
let out = draw(&brush(Some(Variation::Pressure), Some(300.0)), &StrokeLayer { width: 2.0, ..ramp.clone() });
assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0));
// Without recorded pressure, half way between the two widths.
assert!(near(bounds(&draw(&brush(Some(Variation::Pressure), Some(300.0)), &stroke(1.0))).height(), 7.0, 1e-6));
// Fixed (or a definition without a mode) keeps Width, whatever the pressure and the second width.
for (mode, max) in [(None, Some(300.0)), (None, None), (Some(Variation::Pressure), None)] {
let out = draw(&brush(mode, max), &ramp);
assert!(near(bounds(&out).height(), 2.0, 1e-6), "{mode:?} {max:?}: {:?}", bounds(&out));
}
// A stroke in the art takes the width at the middle of its path: 50 % and 300 % for an even
// light and heavy pressure, 175 % without recorded pressure.
let mut stroked = brush(Some(Variation::Pressure), Some(300.0));
if let BrushKind::Art(a) = &mut stroked.kind {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((10.0, 0.0));
a.art = Node::path(NodeId(0), vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
}
for (st, want) in [(pressed(vec![(0.0, 0.0), (1.0, 0.0)]), 1.0), (pressed(vec![(0.0, 1.0), (1.0, 1.0)]), 6.0), (stroke(1.0), 3.5)] {
let out = draw(&stroked, &st);
let w = out.first().and_then(|n| n.appearance.stroke()).map(|s| s.width);
assert!(w.is_some_and(|w| near(w, want, 1e-6)), "{want}: {w:?}");
}
// Stretch Between Guides: an end outside the guides keeps the proportions of the width at its
// end of the stroke. A 2 pt marker at the start of 10 × 4 pt art (guides at 20 % and 80 %),
// 100 % to 300 % wide: 2 pt long at the lightest pressure, 6 pt at the heaviest.
let marker =
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -1.0, 2.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let mut spacer = rect_art(10.0, 4.0);
spacer.appearance = Appearance::default().into();
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
let mut guided = art_brush(art, ArtScale::BetweenGuides { start: 0.2, end: 0.8 });
if let BrushKind::Art(a) = &mut guided.kind {
(a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0));
}
for (p, long) in [(0.0, 2.0), (1.0, 6.0)] {
let out = draw(&guided, &pressed(vec![(0.0, p), (1.0, p)]));
let m = out.first().and_then(|g| g.children()).and_then(|c| c.get(1)).and_then(|m| m.geometric_bounds());
assert!(m.is_some_and(|m| near(m.x0, 0.0, 1e-6) && near(m.x1, long, 1e-6)), "{p}: {m:?}");
}
// A mode the width does not take is read as Fixed, and the second width is kept within range.
let lib = parse_library(&serde_json::json!([
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 1e308},
{"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "pressure", "width_max": 0.0},
]));
let [BrushKind::Art(a), BrushKind::Art(b)] = [&lib[0].kind, &lib[1].kind] else { panic!("{lib:?}") };
assert_eq!((a.width_mode, a.width_max, b.width_mode, b.width_max), (None, Some(1000.0), Some(Variation::Pressure), Some(1.0)));
// A brush without the new fields is written back without them.
let plain = serde_json::to_value(art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)).unwrap();
assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}");
}
/// Definitions from files and commands are kept within their ranges.
#[test]
fn sanitize_keeps_values_within_their_ranges() {
+64 -11
View File
@@ -169,6 +169,33 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
}
/// [`bend`], with the cross scale at arc length `s` multiplied by `widen(s)` (0..=1). The strokes
/// of each object in the art take the factor at the middle of the object.
fn bend_widened(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64, widen: &dyn Fn(f64) -> f64) -> Option<Node> {
let cy = b.center().y;
let x0 = b.x0;
let f = move |p: Point| {
let s = along.at(p.x - x0);
t.map(s, (p.y - cy) * cross * widen(s))
};
let factor = |n: &Node| n.geometric_bounds().map_or(1.0, |r| widen(along.at(r.center().x - x0)));
let art = widen_strokes(art, &factor);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
}
/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they
/// belong to.
fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node {
let mut out = n.clone();
out.appearance.scale_strokes(factor(n));
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind {
*children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect();
}
out
}
/// Arc-length spans to place art on when [`ArtBrush::overlap`] is on: one run between each sharp
/// corner (the whole path when it has none).
fn art_runs(t: &Track) -> Vec<(f64, f64)> {
@@ -189,7 +216,17 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> {
bounds.windows(2).map(|w| (w[0], w[1])).collect()
}
fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cross: f64) -> Option<Node> {
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and
/// `width` the width (%) there.
fn art_on_run(
t: &Track,
(s0, s1): (f64, f64),
a: &ArtBrush,
(art, b): (&Node, Rect),
cross: f64,
width: f64,
widen: &dyn Fn(f64) -> f64,
) -> Option<Node> {
let l = s1 - s0;
if l <= 1e-9 {
return None;
@@ -199,33 +236,49 @@ fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cr
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, s0, s1), k * a.width / 100.0)
(AlongMap::linear(w, s0, s1), k * width / 100.0)
}
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
let fixed = (g0 + (w - g1)) * cross;
// The ends keep the proportions of the width at their end of the run.
let (c0, c1) = (cross * widen(s0), cross * widen(s1));
let fixed = g0 * c0 + (w - g1) * c1;
if fixed >= l || g1 - g0 < 1e-9 {
(AlongMap::linear(w, s0, s1), cross)
} else {
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * cross, s1 - (w - g1) * cross, s1] }, cross)
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * c0, s1 - (w - g1) * c1, s1] }, cross)
}
}
};
bend(t, art, b, along, cross)
bend_widened(t, art, b, along, cross, widen)
}
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as
/// wide as the pressure recorded at each point of the stroke gives (half way without a recording).
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec<Node> {
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
let cross = weight * a.width / 100.0;
let (light, heavy) = a.width_range();
let width = light.max(heavy);
let cross = weight * width / 100.0;
let mut out = vec![];
for t in tracks(bp, 0.05) {
if t.len() <= 1e-9 {
let l = t.len();
if l <= 1e-9 {
continue;
}
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, t.len())] };
for (s0, s1) in runs {
if let Some(n) = art_on_run(a, &t, s0, s1, &art, b, cross) {
let widen = |s: f64| {
if light == heavy || width <= 0.0 {
return 1.0;
}
// A run past the start of a closed track wraps round to it.
let at = if s > l { s - l } else { s };
let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0);
(light + (heavy - light) * p) / width
};
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] };
for run in runs {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) {
out.push(n);
}
}
+15 -4
View File
@@ -1,6 +1,7 @@
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use moxcms::{
@@ -8,7 +9,7 @@ use moxcms::{
RenderingIntent, ToneReprCurve, TransformF32Executor, TransformOptions, Vector3d,
};
use super::{CmsError, Intent, ProfileKind, lab};
use super::{CmsError, Intent, ProfileKind, lab, profiles_generation};
fn moxcms_intent(i: Intent) -> RenderingIntent {
match i {
@@ -27,12 +28,17 @@ fn intent_index(i: Intent) -> usize {
type Xf = Option<Arc<TransformF32Executor>>;
/// A direct transform's cache key: destination name, intent index, black-point compensation.
type DirectKey = (String, usize, bool);
/// A direct transform's cache key: destination [`IccProfile::id`], [`profiles_generation`] it was
/// built in, intent index, black-point compensation. The id tells a reloaded profile from the old
/// one of the same name, which settings built before the reload still convert into; the
/// generation only lets the cache drop what reloads left behind.
type DirectKey = (u64, u64, usize, bool);
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
pub struct IccProfile {
pub name: String,
/// Unique to this instance: never reused, unlike its name or address.
id: u64,
pub kind: ProfileKind,
/// The file it was loaded from (empty for profiles made in code).
source: Vec<u8>,
@@ -82,8 +88,10 @@ impl IccProfile {
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
};
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
Ok(Self {
name,
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
kind,
source: Vec::new(),
profile,
@@ -182,8 +190,11 @@ impl IccProfile {
if dest.kind != ProfileKind::Rgb {
return None;
}
let key = (dest.name.clone(), intent_index(intent), bpc);
let generation = profiles_generation();
let key = (dest.id, generation, intent_index(intent), bpc);
let mut cache = self.direct.lock().unwrap_or_else(|e| e.into_inner());
// an older generation's entries can't match again: drop them so reloads don't pile up
cache.retain(|(k, _)| k.1 == generation);
if let Some((_, t)) = cache.iter().find(|(k, _)| *k == key) {
return t.clone();
}
+46
View File
@@ -180,3 +180,49 @@ fn aliased_localized_names_are_refused() {
assert!(super::icc::localized_text_fits(&profile(3, 64)));
assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10)));
}
/// Reloading an RGB profile under the name it had (a new file for "My RGB") changes what CMYK
/// converts to in it: the direct CMYK → RGB transform isn't kept from the old file.
#[test]
fn a_reloaded_rgb_profile_replaces_the_direct_cmyk_transform_into_it() {
let cmyk = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Reload Test CMYK".into())).unwrap().name;
let rgb = "Reload Test RGB";
let cyan = [1.0, 0.0, 0.0, 0.0];
let convert = || {
let cms = Cms::new(&ColorSettings { rgb: rgb.into(), cmyk: cmyk.clone(), ..ColorSettings::default() }).unwrap();
cms.cmyk_to_rgb(cyan, Intent::RelativeColorimetric)
};
register_icc(&icc::builtin_rgb_bytes(SRGB).unwrap(), Some(rgb.into())).unwrap();
let in_srgb = convert();
register_icc(&icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap(), Some(rgb.into())).unwrap();
let in_prophoto = convert();
// What a CMYK profile loaded afresh gives in the new file.
let fresh = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Reload Test CMYK 2".into())).unwrap().name;
let want = Cms::new(&ColorSettings { rgb: rgb.into(), cmyk: fresh, ..ColorSettings::default() })
.unwrap()
.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}");
assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}");
}
/// The app loads a profile and only then switches to the new settings, so the working spaces
/// built before the reload keep converting in between: a CMYK color they convert after it doesn't
/// leave the old RGB file's transform for the settings built afterwards.
#[test]
fn settings_built_before_a_reload_dont_leave_the_old_transform_to_the_new_ones() {
let cmyk = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Stale Test CMYK".into())).unwrap().name;
let rgb = "Stale Test RGB";
let cyan = [1.0, 0.0, 0.0, 0.0];
let settings = ColorSettings { rgb: rgb.into(), cmyk: cmyk.clone(), ..ColorSettings::default() };
register_icc(&icc::builtin_rgb_bytes(SRGB).unwrap(), Some(rgb.into())).unwrap();
let old = Cms::new(&settings).unwrap();
let in_srgb = old.cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
register_icc(&icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap(), Some(rgb.into())).unwrap();
assert_eq!(old.cmyk_to_rgb(cyan, Intent::RelativeColorimetric), in_srgb, "the old settings keep the old file");
let in_prophoto = Cms::new(&settings).unwrap().cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
// What a CMYK profile loaded afresh gives in the new file.
let fresh = register_icc(&icc_bytes(GENERIC_CMYK).unwrap(), Some("Stale Test CMYK 2".into())).unwrap().name;
let want = Cms::new(&ColorSettings { cmyk: fresh, ..settings }).unwrap().cmyk_to_rgb(cyan, Intent::RelativeColorimetric);
assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}");
assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}");
}
+10 -3
View File
@@ -20,8 +20,9 @@ use std::sync::{Arc, RwLock};
use crate::{Node, NodeId};
/// Hasher for node ids: one multiply. Ids are small counters, not attacker-chosen keys, and the
/// default SipHash dominated the cost of building the cache.
/// Hasher for node ids: one multiply, by a number chosen at random once per run (a document's ids
/// are its author's, and a fixed multiplier lets them be picked to collide). The default SipHash
/// dominated the cost of building the cache.
#[derive(Default)]
pub struct IdHasher(u64);
@@ -32,13 +33,19 @@ impl Hasher for IdHasher {
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0 ^ v).wrapping_mul(0x9e37_79b9_7f4a_7c15);
self.0 = (self.0 ^ v).wrapping_mul(multiplier());
}
fn finish(&self) -> u64 {
self.0 ^ (self.0 >> 29)
}
}
/// The odd multiplier of [`IdHasher`], random per run.
fn multiplier() -> u64 {
static KEY: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*KEY.get_or_init(|| std::hash::BuildHasher::hash_one(&std::hash::RandomState::new(), 0x9e37_79b9_7f4a_7c15_u64) | 1)
}
/// A map keyed by node id with [`IdHasher`].
pub type IdMap<V> = HashMap<NodeId, V, BuildHasherDefault<IdHasher>>;
/// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among
+91 -3
View File
@@ -360,10 +360,12 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
};
}
}
// Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`).
let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::<f64>().is_ok();
if let Some((p, pt)) = s.split_once('p')
&& !p.is_empty()
&& p.trim().parse::<f64>().is_ok()
&& (pt.is_empty() || pt.trim().parse::<f64>().is_ok())
&& plain(p)
&& (pt.is_empty() || plain(pt))
&& !s.ends_with("pt")
&& !s.ends_with("px")
{
@@ -619,6 +621,29 @@ pub struct SavedView {
pub rotation: f64,
}
impl SavedView {
/// Most views a document keeps (the View menu lists every one).
pub const MAX: usize = 25;
/// Longest name, in characters.
pub const MAX_NAME: usize = 255;
/// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank).
pub fn clean_name(name: &str) -> Option<String> {
let name: String = name.trim().chars().take(Self::MAX_NAME).collect();
(!name.is_empty()).then_some(name)
}
/// The first "View N" none of `saved` is named.
pub fn default_name(saved: &[SavedView]) -> String {
(1..=saved.len() + 1).map(|i| format!("View {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default()
}
/// Can the canvas show this view: a finite centre and a finite, positive zoom.
pub fn is_usable(&self) -> bool {
self.center.x.is_finite() && self.center.y.is_finite() && self.zoom.is_finite() && self.zoom > 0.0
}
}
/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at
/// the bottom of the Select menu.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -849,7 +874,7 @@ pub struct Document {
pub graph_designs: Vec<GraphDesign>,
#[serde(default)]
pub guides: Vec<Guide>,
/// View → New View… (up to 25, like Illustrator).
/// View → New View… (at most [`SavedView::MAX`]); saved with the document.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<SavedView>,
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
@@ -1841,6 +1866,40 @@ impl Document {
}
art
}
/// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers,
/// hidden and locked ones and those on template layers included, whose visual bounds (symbol
/// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip
/// group) counts by its members, so one whose members sit on two other artboards leaves the one
/// between them empty. A clip group or clipping layer counts by its members inside its clip (and
/// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork
/// (Delete Empty Artboards).
pub fn artboard_has_art(&self, rect: Rect) -> bool {
// As in `art_on_artboard`: a millionth of a point covers rounding at the edges.
let rect = rect.inflate(1e-6, 1e-6);
fn meets(b: Option<Rect>, r: Rect) -> bool {
b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
}
fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool {
match &c.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
let Some((clip, rest)) = children.split_first() else { return false };
let paint = clip.clip_paint();
if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) {
return true;
}
// The members show only inside the clip's box.
let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false };
let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1));
rest.iter().any(|m| has(m, s, inside))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)),
NodeKind::Path { guide: true, .. } => false,
_ => meets(c.visual_bounds_with(s), rect),
}
}
let symbols = self.symbol_boxes();
self.layers.iter().any(|l| has(l, &symbols, rect))
}
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
/// locked art does.
@@ -2098,6 +2157,28 @@ impl Document {
self.saved_selections = kept.into();
}
/// Saved views as a file gives them, made safe to list and go to: at most [`SavedView::MAX`],
/// each with a clean, unique name, a finite centre and a finite, positive zoom (a rotation
/// that isn't finite becomes 0°).
pub fn tidy_views(&mut self) {
if self.views.is_empty() {
return;
}
let mut kept: Vec<SavedView> = vec![];
for v in std::mem::take(&mut self.views) {
if kept.len() >= SavedView::MAX {
break;
}
let Some(name) = SavedView::clean_name(&v.name) else { continue };
if !v.is_usable() || kept.iter().any(|k| k.name == name) {
continue;
}
let rotation = if v.rotation.is_finite() { v.rotation } else { 0.0 };
kept.push(SavedView { name, rotation, ..v });
}
self.views = kept;
}
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
/// mask already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {
@@ -2713,6 +2794,13 @@ mod tests {
assert_eq!(Unit::Points.parse("-2p"), Some(-24.0));
assert_eq!(Unit::Points.parse("2p6"), Some(30.0));
}
#[test]
fn pica_form_rejects_exponent_notation() {
for s in ["1e5p", "infp", "nanp", "infp3", "1p1e2"] {
assert_eq!(Unit::Points.parse(s), None, "{s}");
}
}
}
#[cfg(test)]
+4 -2
View File
@@ -142,8 +142,10 @@ impl Document {
let ids: Vec<NodeId> = list.as_array().into_iter().flatten().filter_map(|v| v.as_u64()).map(NodeId).collect();
// The old list marked spot-linked blacks too (so the spot names don't matter).
let op = OverprintBlack { spot: true, ..Default::default() };
for id in ids {
if let Some(n) = self.node_mut(id) {
// One walk of the tree finds them all (a missing id costs a walk each, and a list can repeat it).
let paths = self.index_paths(ids);
for path in paths.values() {
if let Some(n) = self.node_mut_at(path) {
op.apply(n, &[]);
}
}
+9 -2
View File
@@ -632,7 +632,12 @@ impl RepeatSpec {
/// Where the instances go, counting toward the budget the Repeats in the art of the symbols
/// of `doc` that instances in the source show (none without a document).
pub fn transforms(&self, doc: Option<&Document>) -> Vec<Affine> {
let mut v = repeat_transforms(&self.kind, self.source_bounds());
self.transforms_from(doc, self.source_bounds())
}
/// [`Self::transforms`] with the source's bounds (as [`Self::source_bounds`] measures them)
/// already at hand: measuring them again, at every level of nested Repeats, doubles the work.
fn transforms_from(&self, doc: Option<&Document>, bounds: Option<Rect>) -> Vec<Affine> {
let mut v = repeat_transforms(&self.kind, bounds);
// Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500
// grid of one is 62.5 billion instances): keep the first that fit the budget.
v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source)));
@@ -681,7 +686,9 @@ impl RepeatSpec {
/// The bounds of all instances of a source measuring `source`, each placed by its transform
/// ([`Self::transforms`] for `doc`).
pub(crate) fn copies_bounds(&self, source: Rect, doc: Option<&Document>) -> Option<Rect> {
self.transforms(doc).into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
// Without a document the source's bounds are the ones `source` is: not measured twice.
let transforms = if doc.is_none() { self.transforms_from(None, Some(source)) } else { self.transforms(doc) };
transforms.into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
+2 -1
View File
@@ -17,6 +17,7 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
.ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
};
let mut out = vec![];
let mut seen = std::collections::HashSet::new();
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.replace('\u{2013}', "-").split_once('-') {
Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")),
@@ -30,7 +31,7 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
return Err(format!("range `{s}`: `{part}` runs backwards"));
}
for i in a - 1..b {
if !out.contains(&i) {
if seen.insert(i) {
out.push(i);
}
}
+2 -1
View File
@@ -212,7 +212,8 @@ pub struct DocSetup {
pub bleed: [f64; 4],
/// Show Images in Outline Mode.
pub outline_images: bool,
/// Highlight text whose font is missing (drawn in a substitute font).
/// The Highlight Substituted Fonts value older builds kept in Document Setup. Files keep it as
/// they were saved; the screen follows Preferences › Type › Highlight Substituted Fonts.
pub highlight_substituted_fonts: bool,
/// Highlight glyphs the font lacks (drawn from a fallback font).
pub highlight_substituted_glyphs: bool,
+18
View File
@@ -274,6 +274,10 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
return vec![];
}
let inside: Vec<Rect> = cut.iter().map(|r| region.intersect(*r)).filter(|r| valid_rect(*r)).collect();
// Every band scans every cut: past a bound the layout is refused, not truncated.
if inside.len() > MAX_AUTO_CUTS {
return vec![];
}
let mut ys: Vec<f64> = inside.iter().flat_map(|r| [r.y0, r.y1]).chain([region.y0, region.y1]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() <= EPS);
@@ -298,6 +302,9 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
// Spans continuing an open slice extend it; the others close.
let mut next = Vec::with_capacity(free.len());
for (a, b) in free {
if done.len() + open.len() + next.len() >= MAX_AUTO_SLICES {
break;
}
match open.iter().position(|r: &Rect| (r.x0 - a).abs() <= EPS && (r.x1 - b).abs() <= EPS) {
Some(i) => {
let mut r = open.swap_remove(i);
@@ -309,7 +316,18 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
}
done.append(&mut open);
open = next;
// Crossing strips make a cell of every crossing: the layout stops, short, past a bound.
if done.len() + open.len() >= MAX_AUTO_SLICES {
break;
}
}
done.append(&mut open);
done.truncate(MAX_AUTO_SLICES);
done
}
/// The most slices automatic layout makes.
const MAX_AUTO_SLICES: usize = 10_000;
/// The most cut rectangles automatic layout works through.
const MAX_AUTO_CUTS: usize = 2_000;
+73 -6
View File
@@ -675,6 +675,19 @@ impl ParaList {
..first.clone()
}
}
/// The list that the Type tool starts when `typed` and a space are typed at the start of a
/// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B,
/// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts
/// none.
pub fn typed(typed: &str) -> Option<ParaList> {
match typed {
"1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)),
"A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)),
"*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)),
_ => None,
}
}
}
/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the
@@ -1524,6 +1537,14 @@ impl TextKind {
}
}
/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the
/// document → text map). False when an anchor doesn't exist.
fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool {
// A distance: only the linear part of the document → text map applies.
let local = to_text * d.to_point() - to_text * Point::ZERO;
refs.iter().all(|&(si, ai)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
}
/// `x` as a fraction of a path's length (0..1; 0 when not finite).
fn fraction(x: f64) -> f64 {
if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }
@@ -1880,6 +1901,17 @@ impl TextObject {
let index = |byte: usize| text.as_bytes().get(..byte).map_or(0, |s| s.iter().filter(|&&c| c == b'\n').count());
index(a)..index(b) + 1
}
/// The list that the characters before byte `at` start ([`ParaList::typed`]) with the byte
/// where their paragraph starts: when the plain text from the start of the paragraph up to
/// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise.
pub fn typed_list_at(&self, at: usize) -> Option<(usize, ParaList)> {
let text = self.plain_text();
let before = text.get(..at)?;
let start = before.rfind('\n').map_or(0, |i| i + 1);
let list = ParaList::typed(before[start..].strip_suffix(' ')?)?;
let index = before.bytes().filter(|&b| b == b'\n').count();
self.para_at(index).list.is_none().then_some((start, list))
}
/// Apply `f` to the attributes of paragraphs `range` (indices; None = every paragraph).
pub fn edit_paras(&mut self, range: Option<std::ops::Range<usize>>, mut f: impl FnMut(&mut ParaStyle)) {
match range {
@@ -2008,21 +2040,28 @@ impl TextObject {
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
self.reshape_area(|frame, to_text| {
// A distance: only the linear part of the document → text map applies.
let local = to_text * d.to_point() - to_text * Point::ZERO;
refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
})
self.reshape_area(|frame, to_text| move_anchors(frame, to_text, refs, d))
}
/// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space,
/// with their handles (a Direct Selection drag of an anchor); the brackets keep their
/// fractions of the path. False, changing nothing, for other type, when an anchor doesn't
/// exist, a degenerate `xf` or a path that would not be finite.
pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d))
}
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
/// when `f` succeeds and the result is finite.
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f)
}
/// [`Self::reshape_area`] for the area frame or type on a path's path.
fn reshape_local(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
let det = self.xf.determinant();
if !det.is_finite() || det.abs() < 1e-12 {
return false;
}
let to_text = self.xf.inverse();
let TextKind::Area { frame } = &mut self.kind else { return false };
let (TextKind::Area { frame } | TextKind::OnPath { path: frame, .. }) = &mut self.kind else { return false };
let mut next = frame.clone();
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
return false;
@@ -2313,6 +2352,34 @@ mod tests {
);
}
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the
/// filled dot. Other text, the same characters later in a paragraph or without the space, and
/// a paragraph already in a list start nothing.
#[test]
fn typed_list_starts_are_found_at_a_paragraphs_start() {
let (arabic, letters, dot) =
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed);
assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some));
for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] {
assert_eq!(ParaList::typed(other), None, "{other:?}");
}
let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default());
assert_eq!(t.typed_list_at(9), Some((6, arabic)));
assert_eq!(t.typed_list_at(19), Some((16, letters)));
assert_eq!(t.typed_list_at(27), Some((25, dot)));
// Not at the paragraph's start, not right after the space, or past the end.
for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] {
assert_eq!(t.typed_list_at(at), None, "{at}");
}
let mut styles = t.paragraph_styles();
styles[1].list = Some(ParaList::bulleted(Bullet::Square));
t.set_paragraph_styles(styles);
assert_eq!(t.typed_list_at(9), None, "a list paragraph stays as it is");
assert_eq!(t.typed_list_at(19), Some((16, letters)));
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
+1 -1
View File
@@ -117,7 +117,7 @@ pub const PATHFINDER_MAX_OVERLAPS: usize = 16_384;
struct Counts(Vec<isize>);
// `isolate_lowest_one` is still unstable in Rust 1.96, and the workspace builds on 1.95 (rust-version).
#[allow(clippy::manual_isolate_lowest_one)]
#[allow(unknown_lints, clippy::manual_isolate_lowest_one)]
impl Counts {
fn add(&mut self, slot: usize, d: isize) {
let mut i = slot + 1;
+502
View File
@@ -0,0 +1,502 @@
//! Brush libraries (Window → Brush Libraries, the Brushes panel's library button): read-only sets
//! of brushes the library panel shows. A library is a document holding the brushes with the
//! resources their art uses (patterns, symbols, swatches, graphic styles, images); saved, it is a
//! native `.vectorcraft` file, which File › Open also opens. The built-in libraries are generated
//! here ([`BUILTIN`]); User Defined ones are the `.vectorcraft` files in the user library folder
//! ([`Libraries`]), and Other Library… loads the brushes of any document VectorCraft opens.
//! `brush.addFromLibrary` copies brushes into the document; `brush.saveLibrary` writes the
//! document's.
use std::collections::HashSet;
use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes};
use super::brushsym::{apply_brush, brush_targets, roots, stroke_paint};
use super::clipboard::Clipboard;
use super::clipboard::resources::SwatchChoices;
use super::patterncmds::add_pattern;
use super::swatch::str_list;
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
use super::*;
/// The extension of saved brush libraries: native documents.
pub const BRUSHES_EXT: &str = vectorcraft_format::EXTENSION;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "brush.libraries",
"Brush Libraries",
[],
None,
"{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the .vectorcraft files in the user library folder, rescanned now)|\"loaded\" (brush.loadLibrary), count, path?}], userFolder} every brush library the library panel opens, in menu order",
always,
list
),
cmd!(
query "brush.library",
"Brush Library",
[],
None,
"{library: id or name} → {id, name, category, brushes: [{name, type}] (as brush.list)}",
always,
get
),
cmd!(
"brush.addFromLibrary",
"Add to Brushes",
["Window", "Brush Libraries"],
None,
"{library: id or name, name? | names?: [brush names in the library] (default: all of it), apply?: false (also apply the first one to paths, as brush.apply does), ids?|id? (the paths apply takes; default the selected ones), swatchConflict? (as edit.paste)} copy library brushes into the document's Brushes as one undo step (with the apply; named Apply Brush when no brush is added and it only applies one), with the resources their art uses (patterns, symbols, swatches, graphic styles, images). A brush the document already has (same name and definition) isn't added again; a name another brush has gets a number (\"Ribbon 2\"). The first one becomes the current brush → {library, names: [each brush's name in the document], added: [names added], existing: [names already there], applied: [ids of the paths it was applied to]}",
has_doc,
add
),
cmd!(
"brush.saveLibrary",
"Save Brush Library…",
["Window", "Brushes"],
None,
"{path?, names?: [brush names] (default: all), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's brushes, with the resources their art uses, as a brush library: a .vectorcraft document holding only them → {path, count, library?: id when saved to the user folder}; without path or user → {dataBase64: the file's bytes, count}",
has_doc,
save
),
cmd!(
"brush.loadLibrary",
"Other Library…",
["Window", "Brush Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load the brushes of any document VectorCraft opens (see document.formats; one without brushes of its own has the default ones), a saved brush library included, for the library panel (Window → Brush Libraries lists it until the app quits; a file of the user library folder, or a file with the same bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
]
}
/// A brush library: its name, its brushes (in panel order) and a document holding them with the
/// resources their art uses; the document has no art of its own.
#[derive(Clone, Debug)]
pub struct BrushLibrary {
pub name: String,
pub brushes: Vec<Brush>,
pub doc: Document,
}
impl Default for BrushLibrary {
fn default() -> Self {
Self { name: String::new(), brushes: vec![], doc: Document::new(100.0, 100.0) }
}
}
impl BrushLibrary {
pub fn len(&self) -> usize {
self.brushes.len()
}
pub fn is_empty(&self) -> bool {
self.brushes.is_empty()
}
pub fn brush(&self, name: &str) -> Option<&Brush> {
self.brushes.iter().find(|b| b.name == name)
}
/// Document `src`'s brushes `names` (empty: all of them, in panel order) as a library named
/// `name`: the brushes and every resource their art uses.
pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result<Self, String> {
let all = brush::library(src);
if let Some(n) = names.iter().find(|n| !all.iter().any(|b| b.name == **n)) {
return Err(format!("no brush `{n}`"));
}
let picked = all.iter().filter(|b| names.is_empty() || names.contains(&b.name));
let clip = Clipboard::gather(src, picked.map(|b| stroke(&b.name)).collect());
let mut doc = clip.to_document();
for l in &mut doc.layers {
if let Some(ch) = Arc::make_mut(l).children_mut() {
ch.clear();
}
}
// The brushes picked and those their art paints with, in the panel order of the document
// they came from.
let brushes: Vec<Brush> = all.into_iter().filter(|b| clip.brushes.iter().any(|c| c.name == b.name)).collect();
brush::store(&mut doc, &brushes);
doc.title = name.clone();
Ok(Self { name, brushes, doc })
}
}
impl LibraryFile for BrushLibrary {
const EXTS: &'static [&'static str] = &[BRUSHES_EXT];
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
let doc = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?;
BrushLibrary::from_document(&doc, &[], stem.to_string())
}
fn name(&self) -> &str {
&self.name
}
}
/// User Defined and loaded brush libraries.
pub type Libraries = super::swatchlib::Libraries<BrushLibrary>;
/// A path stroked with brush `name` (what the resource gathering follows).
fn stroke(name: &str) -> Node {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = ap.stroke_mut() {
st.brush = Some(name.into());
}
let line = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false);
Node::path(NodeId(0), PathData::single(line), ap)
}
// ---------- built-in libraries ----------
/// A library generated in code.
pub struct BuiltinBrushLibrary {
pub id: &'static str,
pub name: &'static str,
make: fn() -> BrushLibrary,
}
/// The built-in brush libraries, in menu order: original brushes generated in code, one library
/// per kind of brush.
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
];
/// Built-in library `id`, made once.
fn builtin_library(id: &str) -> Option<Arc<BrushLibrary>> {
static MADE: LazyLock<Vec<Arc<BrushLibrary>>> = LazyLock::new(|| BUILTIN.iter().map(|b| Arc::new((b.make)())).collect());
BUILTIN.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned())
}
/// Library `name` of `brushes`, `doc` holding the resources their art uses.
fn built(name: &str, mut doc: Document, brushes: Vec<Brush>) -> BrushLibrary {
brush::store(&mut doc, &brushes);
doc.title = name.into();
BrushLibrary { name: name.into(), brushes, doc }
}
fn filled(p: PathData, paint: Paint) -> Node {
Node::path(NodeId(0), p, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], ..Default::default() })
}
fn black(p: PathData) -> Node {
filled(p, Paint::solid(Color::BLACK))
}
fn group(children: Vec<Node>) -> Node {
Node::group(NodeId(0), children.into_iter().map(Arc::new).collect())
}
fn polygon(pts: &[(f64, f64)]) -> PathData {
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
PathData::single(SubPath::polyline(&pts, true))
}
/// A band along y = `f(x)` for x in 0..`len`, `thick` pt thick, in `n` steps.
fn band(len: f64, thick: f64, n: usize, f: impl Fn(f64) -> f64) -> PathData {
let xs: Vec<f64> = (0..=n).map(|i| len * i as f64 / n as f64).collect();
let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, f(x) - thick / 2.0)).collect();
pts.extend(xs.iter().rev().map(|&x| Point::new(x, f(x) + thick / 2.0)));
PathData::single(SubPath::polyline(&pts, true))
}
/// An even-odd ring of radii `outer` and `inner` around `c`.
fn ring(c: Point, outer: f64, inner: f64) -> Node {
let disc = |r: f64| shapes::ellipse(Rect::new(c.x - r, c.y - r, c.x + r, c.y + r));
let children = [disc(outer), disc(inner)].into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))], ..Default::default() }.into();
n
}
fn art(name: &str, art: Node, colorization: Colorization) -> Brush {
Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, colorization, ..Default::default() }) }
}
/// Art Ribbons: art brushes, 100 pt long, drawn here; Striped Ribbon is filled with the library's
/// own pattern, Pinstripes.
fn art_ribbons() -> BrushLibrary {
let mut doc = Document::new(100.0, 100.0);
let stripe = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 6.0, 1.5)), {
let mut a = Appearance::default();
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::from_hex("#2f80c9").unwrap_or(Color::BLACK)))));
a
});
add_pattern(&mut doc, PatternDef { tile: Rect::new(0.0, 0.0, 6.0, 3.0), ..PatternDef::new("Pinstripes", vec![Arc::new(stripe)]) });
let notched = polygon(&[(0.0, -3.0), (100.0, -3.0), (95.0, 0.0), (100.0, 3.0), (0.0, 3.0), (5.0, 0.0)]);
let double = group(vec![black(shapes::rectangle(Rect::new(0.0, -2.5, 100.0, -1.5))), black(shapes::rectangle(Rect::new(0.0, 1.5, 100.0, 2.5)))]);
let wave = band(100.0, 1.6, 64, |x| 2.5 * (x / 100.0 * 4.0 * std::f64::consts::TAU).sin());
// Round and thick at the start, tapering to a point.
let teardrop = {
let n = 48;
let half = |x: f64| 3.5 * (1.0 - x / 100.0).powf(0.8) * (x / 4.0).min(1.0).sqrt();
let xs = |i: usize| 100.0 * i as f64 / n as f64;
let mut pts: Vec<Point> = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect();
pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x))));
PathData::single(SubPath::polyline(&pts, true))
};
let striped = {
let mut n =
filled(shapes::rectangle(Rect::new(0.0, -4.0, 100.0, 4.0)), Paint::Pattern { pattern: "Pinstripes".into(), xf: Default::default() });
n.appearance.set_stroke(Paint::solid(Color::from_hex("#1d4f7c").unwrap_or(Color::BLACK)));
n
};
let brushes = vec![
art("Notched Ribbon", black(notched), Colorization::Tints),
art("Double Line", double, Colorization::Tints),
art("Wave", black(wave), Colorization::Tints),
art("Teardrop", black(teardrop), Colorization::Tints),
art("Striped Ribbon", striped, Colorization::None),
];
built("Art Ribbons", doc, brushes)
}
fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
/// Calligraphic Nibs: pen nibs of several shapes; Pressure Pen's size follows the pen pressure.
fn calligraphic_nibs() -> BrushLibrary {
let pressure = Brush {
name: "Pressure Pen".into(),
kind: BrushKind::Calligraphic(Calligraphic {
angle: 30.0,
roundness: 60.0,
size: 5.0,
variation: [0.0, 0.0, 4.0],
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
}),
};
let brushes = vec![
nib("Fine Nib", 45.0, 25.0, 2.0),
nib("Broad Nib", 45.0, 15.0, 9.0),
nib("Chisel", 0.0, 8.0, 6.0),
nib("Round Pen", 0.0, 100.0, 4.0),
pressure,
];
built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes)
}
fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush {
let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() });
Brush { name: name.into(), kind }
}
/// An invisible rectangle that fixes a tile's width.
fn spacer(r: Rect) -> Node {
Node::path(NodeId(0), shapes::rectangle(r), Appearance::default())
}
/// Pattern Borders: pattern brushes of tiles drawn here; Beads has corner tiles.
fn pattern_borders() -> BrushLibrary {
let zigzag = black(polygon(&[(0.0, 1.4), (5.0, -2.6), (10.0, 1.4), (10.0, 2.6), (5.0, -1.4), (0.0, 2.6)]));
let beads = group(vec![black(shapes::rectangle(Rect::new(0.0, -0.3, 8.0, 0.3))), black(shapes::ellipse(Rect::new(1.5, -2.5, 6.5, 2.5)))]);
let dash_dot = group(vec![
spacer(Rect::new(0.0, -1.0, 12.0, 1.0)),
black(shapes::rounded_rectangle(Rect::new(0.5, -0.75, 6.5, 0.75), 0.75)),
black(shapes::ellipse(Rect::new(8.5, -1.0, 10.5, 1.0))),
]);
let scallops = black(band(10.0, 1.0, 20, |x| -3.0 * (x / 10.0 * std::f64::consts::PI).sin()));
let brushes = vec![
border("Zigzag", zigzag, None, PatternFit::Stretch),
border("Beads", beads, Some(ring(Point::ZERO, 3.5, 1.5)), PatternFit::Stretch),
border("Dash Dot", dash_dot, None, PatternFit::AddSpace),
border("Scallops", scallops, None, PatternFit::Stretch),
];
built("Pattern Borders", Document::new(100.0, 100.0), brushes)
}
fn scatter(name: &str, art: Node, s: Scatter) -> Brush {
Brush { name: name.into(), kind: BrushKind::Scatter(Scatter { art, ..s }) }
}
/// Scatter Shapes: scatter brushes of shapes drawn here; Sparkles scatters the library's own
/// symbol, Sparkle.
fn scatter_shapes() -> BrushLibrary {
let mut doc = Document::new(100.0, 100.0);
// A four-pointed gold sparkle in the ±10 pt box symbol instances assume.
let points: Vec<Point> = (0..8)
.map(|i| {
let r = if i % 2 == 0 { 10.0 } else { 2.5 };
let a = std::f64::consts::FRAC_PI_4 * i as f64;
Point::new(r * a.cos(), r * a.sin())
})
.collect();
let gold = Color::from_hex("#e0a526").unwrap_or(Color::BLACK);
let sparkle = Node::path(doc.alloc_id(), PathData::single(SubPath::polyline(&points, true)), {
let mut a = Appearance::default();
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(gold))));
a
});
doc.symbols.push(Symbol { name: "Sparkle".into(), art: Arc::new(sparkle) });
let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Sparkle".into(), xf: Affine::scale(0.3) });
let random = |a: f64, b: f64| (a, b);
let base = Scatter { colorization: Colorization::Tints, ..Default::default() };
let brushes = vec![
scatter(
"Stars",
black(shapes::star(Point::ZERO, 4.0, 1.7, 5, 0.0)),
Scatter {
size: random(70.0, 130.0),
spacing: random(150.0, 220.0),
scatter: random(-80.0, 80.0),
rotation: random(-180.0, 180.0),
..base.clone()
},
),
scatter("Rings", ring(Point::ZERO, 2.5, 1.5), Scatter { spacing: (140.0, 140.0), ..base.clone() }),
scatter(
"Triangles",
black(shapes::polygon(Point::ZERO, 3.0, 3, 0.0)),
Scatter { spacing: (120.0, 120.0), rotation: (90.0, 90.0), rotation_relative_to_path: true, ..base.clone() },
),
scatter(
"Sparkles",
instance,
Scatter {
size: random(60.0, 140.0),
spacing: random(110.0, 180.0),
scatter: random(-60.0, 60.0),
rotation: random(-45.0, 45.0),
colorization: Colorization::None,
..base
},
),
];
built("Scatter Shapes", doc, brushes)
}
// ---------- commands ----------
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }
}
/// Every brush library, in menu order: built-in, User Defined, loaded.
pub fn libraries(s: &Session) -> Vec<LibraryInfo> {
BUILTIN.iter().map(builtin_info).chain(s.brush_libraries.infos().cloned()).collect()
}
/// Library `key` (an id, or a name in any case) with its info.
pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<BrushLibrary>)> {
if let Some(found) = s.brush_libraries.get(key) {
return Some(found);
}
if let Some(b) = BUILTIN.iter().find(|b| b.id == key) {
return Some((builtin_info(b), builtin_library(key)?));
}
let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id;
library(s, &id)
}
fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<BrushLibrary>)> {
let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see brush.libraries)"))?;
library(s, key).ok_or_else(|| bad(cmd, format!("no brush library `{key}` (see brush.libraries)")))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
s.brush_libraries.rescan();
let libs: Vec<Value> = libraries(s)
.into_iter()
.map(|l| {
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)})
})
.collect();
Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() }))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let (info, lib) = library_param(s, p, "brush.library")?;
let brushes: Vec<Value> = lib.brushes.iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect();
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "brushes": brushes}))
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.addFromLibrary";
let (info, lib) = library_param(s, p, C)?;
let mut names = str_list(p, "names");
names.extend(str_param(p, "name").map(str::to_string));
if let Some(n) = names.iter().find(|n| lib.brush(n).is_none()) {
return Err(bad(C, format!("no brush `{n}` in `{}`", lib.name)));
}
let mut picked: Vec<String> = if names.is_empty() { lib.brushes.iter().map(|b| b.name.clone()).collect() } else { names };
// Each once, in the order asked for.
let mut seen = HashSet::new();
picked.retain(|n| seen.insert(n.clone()));
let apply = bool_or(p, "apply", false);
if apply && picked.is_empty() {
return Err(bad(C, format!("`{}` has no brushes to apply", lib.name)));
}
let choices = SwatchChoices::parse(C, p)?;
let clip = Clipboard::gather(&lib.doc, picked.iter().map(|n| stroke(n)).collect());
// The typing `s.edit` would settle first is settled before the probe below, so the document
// the edit installs is the one it starts from.
if !s.previewing && super::typing_in_progress(s) {
super::settle_interaction(s)?;
}
let st = s.doc()?;
let before: HashSet<String> = brush::library(&st.doc).into_iter().map(|b| b.name).collect();
// A library brush's name in the document: its own, or the one the import gave it.
let target = |renamed: &[Value], n: &str| -> String {
let to = renamed.iter().find(|r| r["kind"] == "brush" && r["from"] == n).and_then(|r| r["to"].as_str());
to.unwrap_or(n).to_string()
};
// Where the brushes land, worked out on a copy that the edit then installs: nothing to add and
// nothing to apply is no edit, and a click that only applies brushes the document has is
// named for that.
let mut probe = (*st.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] };
let paint = stroke_paint(s);
if imported.added > 0 || !paths.is_empty() {
let label = if imported.added > 0 { "Add to Brushes" } else { "Apply Brush" };
s.edit(label, |d, _| {
*d = probe;
if let Some(first) = doc_names.first() {
d.unknown.insert(brush::CURRENT_KEY.into(), json!(first));
apply_brush(d, &paths, first, &paint);
}
Ok(())
})?;
} else if let Some(first) = doc_names.first() {
s.execute("brush.setCurrent", &json!({ "name": first }))?;
}
let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n));
let applied: Vec<u64> = paths.iter().map(|id| id.0).collect();
Ok(json!({"library": info.id, "names": doc_names, "added": added, "existing": existing, "applied": applied}))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.saveLibrary";
let st = s.doc()?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = BrushLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?;
if lib.is_empty() {
return Err(bad(C, "the document has no brushes to save"));
}
let mut out = json!({ "count": lib.len() });
let bytes = vectorcraft_format::save_file(&lib.doc);
s.brush_libraries.write(p, &lib.name, BRUSHES_EXT, FileData::Binary(bytes), &mut out, C)?;
Ok(out)
}
fn load(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.loadLibrary";
let (info, lib) = s.brush_libraries.load(p, C, |bytes, file| {
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
if loaded.preview_only {
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
}
BrushLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
})?;
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
}
+38 -20
View File
@@ -117,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Symbol…",
[],
None,
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
has_selection,
symbol_new
),
@@ -191,7 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Redefine Symbol",
[],
None,
"{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}",
"{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}",
has_selection,
symbol_update
),
@@ -249,7 +249,7 @@ fn ids_json(ids: &[NodeId]) -> Value {
Value::Array(ids.iter().map(|i| Value::from(i.0)).collect())
}
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
pub(crate) fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(mut v) => {
// Explicit ids: only existing, non-layer objects, none inside another of them.
@@ -269,7 +269,7 @@ fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
// ---------- brushes ----------
/// Brushable nodes (paths and compound paths) under `ids`.
fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for id in ids {
let Some(n) = d.node(*id) else { continue };
@@ -302,6 +302,27 @@ fn brush_selection_art(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) }))
}
/// The stroke paint a brush gives paths without one: the current stroke colour, else black.
pub(crate) fn stroke_paint(s: &Session) -> Paint {
if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() }
}
/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`).
pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) {
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) {
n.appearance.set_stroke(stroke.clone());
}
if let Some(st) = n.appearance.stroke_mut() {
if st.width <= 0.0 {
st.width = 1.0;
}
st.brush = Some(name.to_string());
}
}
}
fn brush_apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.apply";
let name = name_param(p, C)?.to_string();
@@ -309,21 +330,10 @@ fn brush_apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no brush named `{name}`")));
}
let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?);
let stroke = if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() };
let stroke = stroke_paint(s);
s.edit("Apply Brush", |d, _| {
d.unknown.insert(brush::CURRENT_KEY.into(), json!(name));
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) {
n.appearance.set_stroke(stroke.clone());
}
if let Some(st) = n.appearance.stroke_mut() {
if st.width <= 0.0 {
st.width = 1.0;
}
st.brush = Some(name.clone());
}
}
apply_brush(d, &ids, &name, &stroke);
Ok(())
})?;
Ok(json!({ "ids": ids_json(&ids) }))
@@ -874,7 +884,8 @@ fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affin
fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.new";
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
super::threads::keep_shown_text(&s.doc()?.doc, &mut art);
let (art, size, c) = normalize(art)?;
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol"));
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
@@ -890,7 +901,10 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
d.set_symbol_export_type(&name, export);
d.set_symbol_nine_slice(&name, nine);
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let threaded = super::threads::frames(d);
let id = replace_with_instance(d, &ids, &name, place)?;
// Frames of threaded text in the symbol leave their threads with the text they show.
super::threads::leave_threads(d, &threaded);
sel.set([id]);
Ok(id)
})?;
@@ -1070,7 +1084,8 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no symbol named `{name}`")));
}
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
super::threads::keep_shown_text(&s.doc()?.doc, &mut art);
let (art, size, c) = normalize(art)?;
let old = natural_size(&s.doc()?.doc, &name);
let id = s.edit("Redefine Symbol", |d, sel| {
@@ -1086,7 +1101,10 @@ fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let reg = d.symbol_registration(&name);
let threaded = super::threads::frames(d);
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
// Frames of threaded text in the symbol leave their threads with the text they show.
super::threads::leave_threads(d, &threaded);
sel.set([id]);
Ok(id)
})?;
+59 -15
View File
@@ -1,10 +1,10 @@
//! Shape Builder (`shapeBuilder.*`), Live Paint (`livePaint.*`) and Image Trace (`imageTrace.*`).
//!
//! **Live Paint groups** need no special node kind: a Live Paint group is a plain group whose name
//! starts with "Live Paint". Its first child is a hidden group named "Live Paint Sources" holding
//! the original paths (the planar map's inputs, used by Release, Merge and re-computation); then
//! come the faces (paths named "Face", fill only) and the edges (open paths named "Edge", stroke
//! only). See `vectorcraft_tools::builder` for the shared helpers.
//! starts with "Live Paint" and that holds a group named "Live Paint Sources". That group is its
//! first child, hidden, and holds the original paths (the planar map's inputs, used by Release,
//! Merge and re-computation); then come the faces (paths named "Face", fill only) and the edges
//! (open paths named "Edge", stroke only). See `vectorcraft_tools::builder` for the shared helpers.
//!
//! **Image Trace** makes a group named "Image Trace" holding the source image (hidden, bottom)
//! and the traced filled paths above it, mapped with the image's transform. Expand drops the
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
has_selection_or_ids,
sb_merge
),
@@ -129,7 +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; Flat Logo names only the size it traced a larger image at)}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -287,9 +287,21 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
f => f,
};
let roots = root_ids(s, p)?;
let gap = gap_param(p);
// The map the Shape Builder tool already has, which a click or drag of it is made over.
let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap);
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched, split) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase || split_tol.is_some(), open_filled_as_closed, gap_param(p));
let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed);
let built;
let map = match &shared {
// The tool's map has the outline edges, which erasing and splitting need.
Some((m, _)) if m.shapes == shapes => m.as_ref(),
_ => {
built = BuilderMap::of_shapes(shapes, erase || split_tol.is_some(), gap);
&built
}
};
let outlines = b::drawn_outlines(d, &leaves);
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() {
@@ -327,7 +339,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() };
touched.extend(&map.hits_along(&pts, piece_tol));
touched.extend(&map.builder_hits_along(&pts, piece_tol));
touched.edges = map.erased_edges(&touched);
if touched.is_empty() {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
@@ -373,7 +385,8 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
let mut rest = if in_union {
// What is left of it: its faces not merged or deleted, united. They share their
// edges with the merged faces exactly, where subtracting the merged shape left
// slivers of the outline along them (#569).
// slivers of the outline along them (#569). A sliver goes in only where the
// shape covers it and the merged shape does not take it in.
let kept: Vec<&po::Region> = map
.regions
.iter()
@@ -381,7 +394,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
.map(|(_, (r, ..))| r)
.collect();
if kept.is_empty() { PathData::default() } else { faces.merge(&kept) }
if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) }
} else {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
};
@@ -987,18 +1000,49 @@ fn trace_params(c: &str, p: &Value) -> Result<tr::TraceParams> {
if !(params.stroke_width.is_finite() && params.stroke_width > 0.0) {
return Err(bad(c, "strokeWidth must be a positive number of pixels"));
}
// A color group is refused unless the trace uses it: Color mode with Document Library and the
// document's swatches.
if params.color_group.as_deref().is_some_and(|g| !g.is_empty()) {
if params.palette != tr::Palette::DocumentLibrary {
return Err(bad(c, "`colorGroup` needs `palette: \"documentLibrary\"`"));
}
if params.library != super::swatchlib::DOCUMENT_SWATCHES {
return Err(bad(c, "`colorGroup` needs `library: \"document\"`"));
}
if params.mode != tr::Mode::Color {
return Err(bad(c, "`colorGroup` needs `mode: \"color\"`"));
}
}
Ok(params)
}
/// Document Library in Color mode: look the library's colours up into `params.swatches` → each
/// one as traced (sRGB) with the colour to fill with. Errors name command `c`.
/// Document Library with `colorGroup`: the solid colors of the active document's color group
/// `name` (so named exactly, else ignoring case). Errors name command `c`.
/// ([`trace_params`] has checked that the library is the document's swatches.)
fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result<Vec<Color>> {
let groups = &s.doc()?.doc.swatch_groups;
let lower = name.to_lowercase();
let group = groups
.iter()
.find(|g| g.name == name)
.or_else(|| groups.iter().find(|g| g.name.to_lowercase() == lower))
.ok_or_else(|| bad(c, format!("no color group `{name}` in the document (see swatch.list)")))?;
Ok(group.swatches.iter().filter_map(|w| w.paint.color()).collect())
}
/// Document Library in Color mode: look the library's colors (or its color group's) up into
/// `params.swatches` → each one as traced (sRGB) with the color to fill with. Errors name command `c`.
fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result<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)")))?;
let group = params.color_group.as_deref().filter(|g| !g.is_empty());
let key = group.unwrap_or(params.library.as_str());
let colors = match group {
Some(name) => trace_group_colors(c, s, name)?,
None => super::swatchlib::library_colors(s, key)
.ok_or_else(|| bad(c, format!("no swatch library `{key}` (see swatch.library.list; \"document\": the document's swatches)")))?,
};
if colors.is_empty() {
return Err(bad(c, format!("`{key}` has no colours")));
}
+1
View File
@@ -23,6 +23,7 @@ use vectorcraft_geom::Rect;
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub(crate) use resources::SwatchChoices;
pub use resources::{ArtResources, art_resources};
use super::*;
use crate::DocState;
+150 -15
View File
@@ -2,7 +2,7 @@
//!
//! Objects refer to resources by name (image blobs, symbols, patterns, swatches, character and
//! paragraph styles, brushes) and to graphic styles by id. Copy follows those references, also
//! through the symbols, patterns and styles it finds, and keeps what they lead to.
//! through the symbols, patterns, styles and brush art it finds, and keeps what they lead to.
//!
//! Pasting matches each resource by name: an identical one in the document is used, a missing one
//! is added, and a different one of the same name is added under a free name, which the pasted
@@ -13,6 +13,7 @@
//! the document's Spot Colors option says. Pasting back into the document the objects came from
//! uses its resources as they are now.
use std::cell::OnceCell;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
@@ -147,10 +148,28 @@ struct Defs<'a> {
styles: &'a [GraphicStyle],
char_styles: &'a [TextStyleDef],
para_styles: &'a [TextStyleDef],
brushes: Brushes<'a>,
}
/// The brushes references lead to: a document's library, read the first time a brush is used
/// (`vectorcraft_brush::library` parses it), or the clipboard's own.
enum Brushes<'a> {
Lazy(&'a Document, &'a OnceCell<Vec<Brush>>),
Ready(&'a [Brush]),
}
impl<'a> Brushes<'a> {
fn get(&self) -> &'a [Brush] {
match *self {
Brushes::Lazy(d, cell) => cell.get_or_init(|| vectorcraft_brush::library(d)),
Brushes::Ready(list) => list,
}
}
}
impl<'a> Defs<'a> {
fn of(d: &'a Document) -> Self {
/// `d`'s definitions; `brushes` holds its brush library once a brush is used.
fn of(d: &'a Document, brushes: &'a OnceCell<Vec<Brush>>) -> Self {
Self {
swatches: d.swatches_iter().collect(),
symbols: &d.symbols,
@@ -158,6 +177,7 @@ impl<'a> Defs<'a> {
styles: &d.graphic_styles,
char_styles: &d.char_styles,
para_styles: &d.para_styles,
brushes: Brushes::Lazy(d, brushes),
}
}
}
@@ -180,9 +200,9 @@ pub(super) struct Used {
}
impl Used {
fn of<'a>(defs: &Defs<'a>, nodes: &'a [Node]) -> Self {
fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator<Item = &'a Node>) -> Self {
let mut used = Used::default();
let mut todo: Vec<Source<'a>> = nodes.iter().map(Source::Node).collect();
let mut todo: Vec<Source<'a>> = nodes.into_iter().map(Source::Node).collect();
while let Some(src) = todo.pop() {
let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo);
match src {
@@ -224,6 +244,8 @@ impl Used {
Res::Pattern => todo.extend(defs.patterns.iter().filter(|p| p.name == name).flat_map(|p| p.art.iter().map(|a| Source::Node(a)))),
Res::CharStyle => todo.extend(defs.char_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))),
Res::ParaStyle => todo.extend(defs.para_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))),
// A scatter or art brush's art, a pattern brush's tiles.
Res::Brush => todo.extend(defs.brushes.get().iter().filter(|b| b.name == name).flat_map(|b| b.art().into_iter().map(Source::Node))),
_ => {}
}
}
@@ -242,6 +264,80 @@ impl Used {
}
}
/// What a preview of some art shows besides the art itself ([`art_resources`]).
pub struct ArtResources {
/// Changes when any resource the art uses changes, and only then.
pub key: u64,
/// The art and image bytes counted in `key` by their address, held weakly: their allocations
/// stay theirs (no other art can take an address) for as long as the key is kept.
_kept: (Vec<std::sync::Weak<Node>>, Vec<std::sync::Weak<Vec<u8>>>),
}
/// The resources `art` uses in `doc`, through the definitions they lead to as Copy follows them
/// ([`Used`]): images, symbols, patterns (tile type, overlap and spacing too), swatches (linked
/// colours, tints, gradient swatches; colour groups' included), character and paragraph styles,
/// brushes and graphic styles, and the document's Spot Colors option. Art and image bytes count
/// by identity, the other definitions by their saved form: working the key out walks `art` and
/// the definitions it leads to, and never reads an image's bytes.
pub fn art_resources<'a>(doc: &'a Document, art: impl IntoIterator<Item = &'a Node>) -> ArtResources {
use std::hash::{Hash, Hasher};
let library = OnceCell::new();
let defs = Defs::of(doc, &library);
// The art's references shortened to the brush library's life.
let mut nodes: Vec<&Node> = vec![];
for n in art {
nodes.push(n);
}
let used = Used::of(&defs, nodes);
let mut h = std::collections::hash_map::DefaultHasher::new();
let mut kept = (vec![], vec![]);
fn saved(h: &mut impl Hasher, v: &impl serde::Serialize) {
serde_json::to_vec(v).unwrap_or_default().hash(h);
}
fn node(h: &mut impl Hasher, kept: &mut Vec<std::sync::Weak<Node>>, a: &Arc<Node>) {
(Arc::as_ptr(a) as usize).hash(h);
kept.push(Arc::downgrade(a));
}
for (res, names) in &used.names {
for name in names {
(*res as u8, name).hash(&mut h);
match res {
Res::Image => {
if let Some(b) = doc.images.get(name) {
b.mime.hash(&mut h);
for bytes in std::iter::once(&b.bytes).chain(&b.proxy) {
(Arc::as_ptr(bytes) as usize).hash(&mut h);
kept.1.push(Arc::downgrade(bytes));
}
}
}
Res::Symbol => doc.symbols.iter().filter(|s| s.name == *name).for_each(|s| node(&mut h, &mut kept.0, &s.art)),
Res::Pattern => {
for p in doc.patterns.iter().filter(|p| p.name == *name) {
saved(&mut h, &PatternDef { art: vec![], ..p.clone() });
p.art.iter().for_each(|a| node(&mut h, &mut kept.0, a));
}
}
Res::CharStyle => doc.char_styles.iter().filter(|s| s.name == *name).for_each(|s| saved(&mut h, s)),
Res::ParaStyle => doc.para_styles.iter().filter(|s| s.name == *name).for_each(|s| saved(&mut h, s)),
Res::Brush => defs.brushes.get().iter().filter(|b| b.name == *name).for_each(|b| saved(&mut h, b)),
// Swatches below (tints count too); graphic styles go by id.
Res::Swatch | Res::GraphicStyle => {}
}
}
}
for w in doc.swatches_iter().filter(|w| used.carries(w) || used.has(Res::Swatch, &w.name)) {
saved(&mut h, w);
}
for g in doc.graphic_styles.iter().filter(|g| g.id != 0 && used.styles.contains(&g.id)) {
saved(&mut h, g);
}
if used.names.contains_key(&Res::Swatch) {
doc.spot_use_lab.hash(&mut h);
}
ArtResources { key: h.finish(), _kept: kept }
}
/// A resource list entry known by its name.
trait Named: Clone {
fn name(&self) -> &str;
@@ -469,6 +565,26 @@ fn same_art(r: &Relink, ours: &[Arc<Node>], theirs: &[Arc<Node>]) -> bool {
ours.len() == theirs.len() && ours.iter().zip(theirs).all(|(a, b)| relinked(&unlinked, a) == relinked(r, b))
}
/// Is `ours` (a document brush) brush `theirs` (a pasted one) once the art of that is relinked by
/// `r`? Graphic style links don't count, as in [`same_art`].
fn same_brush(r: &Relink, ours: &Brush, theirs: &Brush) -> bool {
let relinked = |r: &Relink, b: &Brush| {
let mut b = b.clone();
b.art_mut().into_iter().for_each(|a| r.node(a));
b
};
relinked(&Relink::default(), ours) == relinked(r, theirs)
}
/// Does brush `b`'s art show symbols or patterns?
fn shows_definitions(b: &Brush) -> bool {
let mut found = false;
for a in b.art() {
node_refs(a, &mut |r| found |= matches!(r, Ref::Name(Res::Symbol | Res::Pattern, _)));
}
found
}
/// What pasting did to the document's resources (part of the paste result).
#[derive(Debug, Default)]
pub(crate) struct Imported {
@@ -501,11 +617,13 @@ impl Imported {
impl Clipboard {
/// `nodes` (from `src`) and the resources of `src` they use.
pub(crate) fn gather(src: &Document, nodes: Vec<Node>) -> Self {
let used = Used::of(&Defs::of(src), &nodes);
let library = OnceCell::new();
let used = Used::of(&Defs::of(src, &library), &nodes);
let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::<Vec<_>>();
let brushes = match used.names.get(&Res::Brush) {
Some(names) => vectorcraft_brush::library(src).into_iter().filter(|b| names.contains(&b.name)).collect(),
None => vec![],
// Read when a brush was used.
let brushes = match (used.names.get(&Res::Brush), library.get()) {
(Some(names), Some(library)) => library.iter().filter(|b| names.contains(&b.name)).cloned().collect(),
_ => vec![],
};
Self {
images: src.images.iter().filter(|(k, _)| used.has(Res::Image, k)).map(|(k, b)| (k.clone(), b.clone())).collect(),
@@ -530,6 +648,7 @@ impl Clipboard {
styles: &self.graphic_styles,
char_styles: &self.char_styles,
para_styles: &self.para_styles,
brushes: Brushes::Ready(&self.brushes),
};
Used::of(&defs, &self.nodes)
}
@@ -549,8 +668,10 @@ impl Clipboard {
let used = self.used();
let mut out = Imported::default();
self.import_images(d, &used, same_doc, &mut out);
self.import_brushes(d, &used, same_doc, &mut out);
self.import_swatches(d, &used, choices, same_doc, &mut out);
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
// they may brush; the others once those are in.
let mut added_brushes = self.import_brushes(d, &used, same_doc, false, &mut out);
// Character and paragraph styles (their colours relinked first).
for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] {
for def in mine.iter().filter(|s| used.has(r, &s.name)) {
@@ -583,6 +704,7 @@ impl Clipboard {
added_patterns.extend(p.new_name(&def.name));
out.placed(Res::Pattern, &def.name, &p);
}
added_brushes.extend(self.import_brushes(d, &used, same_doc, true, &mut out));
self.import_graphic_styles(d, &used, same_doc, &mut out);
for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) {
out.relink.node(Arc::make_mut(&mut s.art));
@@ -592,6 +714,13 @@ impl Clipboard {
out.relink.node(Arc::make_mut(a));
}
}
if !added_brushes.is_empty() {
let mut lib = vectorcraft_brush::library(d);
for b in lib.iter_mut().filter(|b| added_brushes.contains(&b.name)) {
b.art_mut().into_iter().for_each(|a| out.relink.node(a));
}
vectorcraft_brush::store(d, &lib);
}
out
}
@@ -607,20 +736,26 @@ impl Clipboard {
}
}
fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, out: &mut Imported) {
let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).collect();
/// The brushes whose art shows symbols or patterns (`late`), or the others: each one the
/// document has (its art compared once relinked) is used, the others are added. → the names of
/// those added, whose art is relinked at the end.
fn import_brushes(&self, d: &mut Document, used: &Used, same_doc: bool, late: bool, out: &mut Imported) -> Vec<String> {
let brushes: Vec<&Brush> = self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name) && shows_definitions(b) == late).collect();
if brushes.is_empty() {
return;
return vec![];
}
let mut lib = vectorcraft_brush::library(d);
let before = lib.len();
let mut added = vec![];
for b in brushes {
let p = place(&mut lib, b, same_doc, PartialEq::eq);
let r = &out.relink;
let p = place(&mut lib, b, same_doc, |ours, theirs| same_brush(r, ours, theirs));
added.extend(p.new_name(&b.name));
out.placed(Res::Brush, &b.name, &p);
}
if lib.len() != before {
if !added.is_empty() {
vectorcraft_brush::store(d, &lib);
}
added
}
/// Global and spot colours (a name conflict merges or adds as `choices` say), then the tint
+6
View File
@@ -195,6 +195,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
// The bytes are kept, not the path: the file may move or go away.
let remembered = s.store_profile(&info.name, &bytes);
// A new file of a working space: the active settings still hold the old one.
let working = cms::active_settings();
if working.rgb == info.name || working.cmyk == info.name {
cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?;
touch_all(s);
}
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
}
+4 -1
View File
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -794,6 +794,9 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
}
if let Some(f) = str_param(p, "style") {
style.font_style = f.to_string();
} else if str_param(p, "font").is_some() {
// A family without a Regular (#4128): its closest style, as text.setRangeStyle takes.
super::textedit::fit_font_style(&mut style);
}
style.fill = match p.get("color").and_then(color_value) {
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
+6 -4
View File
@@ -209,8 +209,8 @@ fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathD
return Some(components(&a).into_iter().chain(components(&b)).collect());
}
// Open paths: the parts on each side (each pass removes the other side).
let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?;
let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?;
let a = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0)?;
let b = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0)?;
Some(if pd.subpaths.len() == 1 {
a.into_iter().chain(b).collect()
} else {
@@ -249,7 +249,9 @@ fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
return Some(if r.is_empty() { vec![] } else { vec![r] });
}
let inside = rect.inflate(1e-9, 1e-9);
let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
// Sampled at least every half of the rectangle's shorter side, so a line through it is found.
let step = rect.width().min(rect.height()) / 2.0;
let pieces = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
pieces
} else {
@@ -317,7 +319,7 @@ fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
return if joined.is_empty() { vec![] } else { vec![joined] };
}
let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) {
let kept = match erase_outline(pd, f64::INFINITY, &|q| axis.side(q) * sign < 0.0) {
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
None => pd.subpaths.clone(),
};
+22 -6
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Setup",
[],
None,
"{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup",
"{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup",
has_doc,
setup
)]
@@ -31,7 +31,6 @@ pub(crate) fn setup_json(d: &Document) -> Value {
"units": d.units.label(),
"bleed": s.bleed,
"outlineImages": s.outline_images,
"highlightSubstitutedFonts": s.highlight_substituted_fonts,
"highlightSubstitutedGlyphs": s.highlight_substituted_glyphs,
"gridSize": s.grid_size.id(),
"gridColors": s.grid_colors.map(|c| c.to_hex()),
@@ -72,9 +71,19 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
}
apply(&mut new, &mut units, k, v, &s.prefs.flattener_presets)?;
}
// An older build's reply passed back still applies; the moved key only gets a warning.
let reply = |d: &Document| {
let mut r = setup_json(d);
if o.contains_key("highlightSubstitutedFonts")
&& let Some(m) = r.as_object_mut()
{
m.insert("warnings".into(), json!([MOVED_FONTS_HIGHLIGHT]));
}
r
};
let st = s.doc()?;
if new == st.doc.setup && units == st.doc.units {
return Ok(setup_json(&st.doc));
return Ok(reply(&st.doc));
}
s.edit("Document Setup", |d, _| {
let scripts_changed =
@@ -86,9 +95,13 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(setup_json(&s.doc()?.doc))
Ok(reply(&s.doc()?.doc))
}
/// The warning for `highlightSubstitutedFonts`, which `document.setup` ignores.
const MOVED_FONTS_HIGHLIGHT: &str =
"highlightSubstitutedFonts is ignored: it is a preference (Preferences › Type), set with prefs.set {key: \"highlightSubstitutedFonts\", value}";
fn flag(k: &str, v: &Value) -> Result<bool> {
v.as_bool().ok_or_else(|| bad(C, format!("{k} must be true or false")))
}
@@ -102,7 +115,10 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value
"units" => *units = v.as_str().and_then(vectorcraft_doc::Unit::named).ok_or_else(|| bad(C, "unknown units"))?,
"bleed" => s.bleed = bleed_param(v, s.bleed, C)?,
"outlineImages" => s.outline_images = flag(k, v)?,
"highlightSubstitutedFonts" => s.highlight_substituted_fonts = flag(k, v)?,
// Preferences › Type holds it for every document: ignored, with a warning in the reply.
"highlightSubstitutedFonts" => {
flag(k, v)?;
}
"highlightSubstitutedGlyphs" => s.highlight_substituted_glyphs = flag(k, v)?,
"gridSize" => s.grid_size = v.as_str().and_then(GridSize::parse).ok_or_else(|| bad(C, "gridSize must be small, medium or large"))?,
"gridColors" => s.grid_colors = grid_colors(v)?,
@@ -125,7 +141,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
"backgroundContents" => s.background = background_param(v, C)?,
// Read-only values `document.setup` reports.
"gridColorsName" => {}
"gridColorsName" | "warnings" => {}
_ => return Err(bad(C, format!("unknown setting `{k}`"))),
}
Ok(())
+149 -34
View File
@@ -12,6 +12,7 @@ use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
use vectorcraft_tools::draw2::{eraser_area, eraser_tip};
use super::create::{add_look, insert_look};
use super::newart::NewArt;
@@ -26,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eraser",
[],
None,
"{points: [[x,y]…], size?: pt (10)} erase a round-brush stroke from paths (selected, or all) → {ids}",
"{points: [[x,y]…], size?: pt (10), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one",
has_doc,
erase_region
),
@@ -410,18 +411,26 @@ pub(super) fn components(pd: &PathData) -> Vec<PathData> {
groups.into_iter().map(PathData::new).collect()
}
/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed.
fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPath>> {
/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. Each segment
/// is sampled at least every `step` pt (and at least 24 times), so a removed stretch at least
/// `step` long is found.
fn cut_subpath(sp: &SubPath, step: f64, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPath>> {
let nseg = sp.segment_count();
if nseg == 0 {
return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]);
}
const K: usize = 24;
const MAX_SAMPLES: usize = 4096;
let mut runs: Vec<Vec<(usize, f64, f64)>> = vec![];
let mut cur: Vec<(usize, f64, f64)> = vec![];
let mut any = false;
for i in 0..nseg {
let c = sp.segment(i);
// The curve moves at most 3 times its longest control-polygon edge per unit of t, so with
// n samples, neighboring samples are at most 3 · edge / n apart.
let edge = (c.p1 - c.p0).hypot().max((c.p2 - c.p1).hypot()).max((c.p3 - c.p2).hypot());
let wanted = (3.0 * edge / step).ceil();
let samples = if wanted.is_finite() && wanted > K as f64 { (wanted as usize).min(MAX_SAMPLES) } else { K };
let mut prev_t = 0.0;
let mut prev_rm = remove(c.eval(0.0));
let mut start = if prev_rm { None } else { Some(0.0) };
@@ -431,8 +440,8 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
runs.push(std::mem::take(&mut cur));
}
}
for k in 1..=K {
let t = k as f64 / K as f64;
for k in 1..=samples {
let t = k as f64 / samples as f64;
let rm = remove(c.eval(t));
if rm != prev_rm {
let (mut lo, mut hi) = (prev_t, t);
@@ -500,13 +509,14 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
Some(out)
}
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
/// Erase from an outline path (open or closed) where `remove` holds, sampling each segment at least
/// every `step` pt (`f64::INFINITY` for the fixed sampling). Returns the new geometry per output
/// object (one object per piece when the path had a single subpath), or None if unchanged.
pub(super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
let mut changed = false;
let mut subs = vec![];
for sp in &pd.subpaths {
match cut_subpath(sp, remove) {
match cut_subpath(sp, step, remove) {
Some(pieces) => {
changed = true;
subs.extend(pieces);
@@ -539,7 +549,7 @@ fn path_kind(path: PathData) -> NodeKind {
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
/// corners) and simplify it to `tol`.
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
let min = (tol * 0.3).max(0.05);
let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIDELITY * 0.3);
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
let mut kept: Vec<Point> = vec![p0];
for &p in pts.iter().skip(1) {
@@ -575,11 +585,21 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
}
/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points
/// joined by straight segments (`path.freehand` with `fit: false`).
fn stroke_subpath(pts: &[Point], tol: Option<f64>, closed: bool) -> SubPath {
match tol {
Some(tol) => fit_freehand(pts, tol, closed),
None => SubPath::new(pts.iter().map(|&p| Anchor::corner(p)).collect(), closed),
}
}
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
/// way round a closed subpath) with the stroke fitted at `tol` (with no `tol`, unfitted). The
/// subpath's index and its new shape; None when the stroke doesn't start and end on the same
/// subpath, or meets it once.
fn redraw_section(pd: &PathData, pts: &[Point], tol: Option<f64>, within: f64) -> Option<(usize, SubPath)> {
use kurbo::ParamCurveArclen;
let (first, last) = (*pts.first()?, *pts.last()?);
let (si, _, _, _, d_a) = pd.nearest(first)?;
@@ -633,7 +653,7 @@ fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option
*s0 = s1.p;
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
*e0 = e1.p;
let fit = fit_freehand(&stroke, tol, false).anchors;
let fit = stroke_subpath(&stroke, tol, false).anchors;
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
@@ -664,7 +684,9 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0);
// The tolerance the stroke is fitted at; with `fit: false` it is not fitted.
let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol);
let closed = switch_or(p, "closed", false, C)?;
let brush = str_param(p, "style") == Some("brush");
let keep_selected = switch_or(p, "keepSelected", true, C)?;
@@ -687,12 +709,41 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
if pts.len() < 2 {
return Ok(json!({ "id": id.0 }));
}
// A stroke that ends on an open end of another path connects the two: (that path, its last
// open subpath, that subpath starting at the end the stroke ends on, it ends at its end).
let connect = match p.get("connect").filter(|v| !v.is_null()) {
Some(cn) => {
let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?;
if other == id {
return Err(bad(C, "connect needs a path other than the one extended"));
}
let (opd, _) = path_of(&s.doc()?.doc, other)?;
let osi =
opd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "connect path has no open end"))?;
let mut y = opd.subpaths[osi].clone();
let at_end = match str_param(cn, "end") {
Some("start") => false,
Some("end") => true,
_ => return Err(bad(C, "connect end must be \"start\" or \"end\"")),
};
if at_end {
y.reverse();
}
Some((other, osi, y, at_end))
}
None => None,
};
let close_tol = (tol * 4.0).max(6.0);
let closing = sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
*l = first;
}
let fit = fit_freehand(&pts, tol, false);
if let Some((_, _, y, _)) = &connect
&& let (Some(l), Some(y0)) = (pts.last_mut(), y.anchors.first())
{
*l = y0.p;
}
let fit = stroke_subpath(&pts, fit_tol, false);
let mut add = fit.anchors;
if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) {
last.h_out = a0.h_out;
@@ -712,12 +763,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
// The path's pressure (in the direction it is continued in) followed by the stroke's.
let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());
let joined = (old_pressure.is_some() || pressure.is_some()).then(|| {
let drawn_len = subpath_len(&sp);
// The other path's anchors follow on from the one the stroke ends on, which takes its
// outgoing handle.
let mut other_len = 0.0;
if let Some((_, _, y, _)) = &connect
&& let Some((y0, rest)) = y.anchors.split_first()
{
if let Some(last) = sp.anchors.last_mut() {
*last = Anchor::with_handles(y0.p, last.h_in + (y0.p - last.p), y0.h_out);
}
sp.anchors.extend_from_slice(rest);
other_len = subpath_len(y);
}
// The path's pressure (in the direction it is continued in) followed by the stroke's, and
// by the pressure of the path it connects to.
let pressure_of = |n: NodeId| s.doc().ok()?.doc.node(n).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());
let old_pressure = pressure_of(id);
let other_pressure = connect.as_ref().and_then(|(o, _, _, at_end)| pressure_of(*o).map(|p| if *at_end { p.reversed() } else { p }));
let joined = (old_pressure.is_some() || pressure.is_some() || other_pressure.is_some()).then(|| {
let old = old_pressure.map(|p| if at_start { p.reversed() } else { p });
let total = subpath_len(&sp);
PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len)
let drawn = PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), drawn_len - old_len);
if connect.is_some() { PressureProfile::extended(drawn.as_ref(), drawn_len, other_pressure.as_ref(), other_len) } else { drawn }
});
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
let reverse = at_start && !sp.closed;
@@ -725,6 +792,31 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
sp.reverse();
}
path_mut(d, id)?.subpaths[si] = sp;
if let Some((other, osi, ..)) = &connect {
let o = path_mut(d, *other)?;
if *osi < o.subpaths.len() {
o.subpaths.remove(*osi);
}
if o.is_empty() {
d.remove(*other)?;
sel.remove(*other);
}
// Direct-selected anchors and segments of the subpaths after the one removed move
// down one; those of the one removed go, and a path left with no selected anchors
// is no longer selected.
let shift = |refs: &mut BTreeSet<(usize, usize)>| {
*refs = refs.iter().filter(|r| r.0 != *osi).map(|&(sub, i)| if sub > *osi { (sub - 1, i) } else { (sub, i) }).collect();
};
if let Some(refs) = sel.anchors.get_mut(other) {
shift(refs);
}
if let Some(refs) = sel.segments.get_mut(other) {
shift(refs);
}
if sel.anchors.get(other).is_some_and(BTreeSet::is_empty) {
sel.remove(*other);
}
}
if let Some(joined) = joined
&& let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut())
{
@@ -748,13 +840,16 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let (pd, _) = path_of(&s.doc()?.doc, id)?;
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
// it to the new length if Calligraphic pressure strokes get redrawn.
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
if let Some((si, sp)) = redraw_section(&pd, &pts, fit_tol, within) {
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
// A member path of a compound path is redrawn in it: the compound path is what is
// selected.
let whole = d.parent_of(id).filter(|p| d.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id);
if keep_selected {
sel.set([id]);
sel.set([whole]);
} else {
sel.remove(id);
sel.remove(whole);
}
Ok(())
})?;
@@ -764,7 +859,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
if pts.len() < 2 {
return Err(bad(C, "need at least two points"));
}
let sp = fit_freehand(&pts, tol, closed);
let sp = stroke_subpath(&pts, fit_tol, closed);
let fill = if filled { s.paint.fill.clone() } else { Paint::None };
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
// gets black. A brush is its stroke.
@@ -1147,9 +1242,17 @@ fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
if !(size.is_finite() && size > 0.0) {
return Err(bad(C, "size must be positive"));
}
let angle = number_or(p, "angle", 0.0, C)?;
let roundness = number_or(p, "roundness", 100.0, C)?;
let r = size / 2.0;
// The tip's short axis: an open path is sampled at least every half of it.
let short = size * roundness.clamp(1.0, 100.0) / 100.0;
let st = s.doc()?;
let region = brush_region(&pts, size);
let region = eraser_area(&pts, size, angle, roundness);
// A point is under the tip when, with the tip squashed back into a circle, it is within the
// circle's radius of the squashed drag.
let squash = eraser_tip(angle, roundness).inverse();
let round: Vec<Point> = pts.iter().map(|q| squash * *q).collect();
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) {
let (pd, rule) = path_of(&st.doc, id)?;
@@ -1158,7 +1261,7 @@ fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
plan.push((id, components(&po::boolean(&pd, rule, &region, FillRule::NonZero, BoolOp::Difference))));
} else if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= r) {
} else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| dist_to_polyline(&round, squash * q) <= r) {
plan.push((id, pieces));
}
}
@@ -1193,7 +1296,7 @@ fn erase_segments(s: &mut Session, p: &Value) -> Result<Value> {
let mut plan = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, w)) {
let (pd, _) = path_of(&st.doc, id)?;
if let Some(pieces) = erase_outline(&pd, &|q| dist_to_polyline(&pts, q) <= w / 2.0) {
if let Some(pieces) = erase_outline(&pd, w / 2.0, &|q| dist_to_polyline(&pts, q) <= w / 2.0) {
plan.push((id, pieces));
}
}
@@ -1329,7 +1432,7 @@ fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.smoothRegion";
let pts = points_param(p, "points", C)?;
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0);
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
let st = s.doc()?;
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
let mut plan = vec![];
@@ -1394,6 +1497,7 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
.min_by(|a, b| a.1.total_cmp(&b.1))
.map_or(0.0, |x| x.0)
};
let (first_pt, last_pt) = (pts.first().copied(), pts.last().copied());
let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) {
let (pd, _) = path_of(&st.doc, id)?;
@@ -1402,9 +1506,20 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
for (start, a) in [(true, sp.anchors[0].p), (false, sp.anchors[sp.anchors.len() - 1].p)] {
if dist_to_polyline(&pts, a) <= tol {
ends.push((order(a), id, si, start, a));
let on_scrub = dist_to_polyline(&pts, a) <= tol;
let near_start = first_pt.is_some_and(|p| a.distance(p) <= tol);
let near_end = last_pt.is_some_and(|p| a.distance(p) <= tol);
if !on_scrub && !near_start && !near_end {
continue;
}
let ord = if on_scrub {
order(a)
} else if near_start && (!near_end || first_pt.is_some_and(|p| a.distance(p) <= a.distance(last_pt.unwrap_or(p)))) {
-1.0
} else {
1e30
};
ends.push((ord, id, si, start, a));
}
}
}
@@ -1515,7 +1630,7 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut inputs: Vec<(&PathData, FillRule)> = vec![(&region, FillRule::NonZero)];
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
let merged = po::unite_all(&inputs);
let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?;
let Some(((keep, _, _), rest)) = merge_ids.split_last() else {
return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected);
};
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
+7 -1
View File
@@ -56,7 +56,13 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "batches cannot nest"));
}
if find_command(id).is_none() {
return Err(bad(C, format!("commands[{i}]: unknown command `{id}`")));
return Err(bad(
C,
match super::super::params::nearest_command(id) {
Some(near) => format!("commands[{i}]: unknown command `{id}`; did you mean `{near}`?"),
None => format!("commands[{i}]: unknown command `{id}`"),
},
));
}
}
let source = str_param(p, "source").unwrap_or("folder");
+45 -3
View File
@@ -1,6 +1,9 @@
//! `command.batch`: several commands as one undo step (in each document they edit).
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use super::super::*;
use crate::{DocState, EngineError};
@@ -23,10 +26,14 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let start = s.docs.clone();
let active = s.active;
s.batch_stash = Some(vec![]);
let r = run_steps(s, &label, artboard, &mut cmds);
let mut grouped = Vec::with_capacity(cmds.len());
let r = run_steps(s, &label, artboard, &mut cmds, &mut grouped);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => results,
Ok(results) => {
s.batch_steps = Some(grouped);
results
}
Err(e) => {
roll_back(s, start, &stash);
s.active = active;
@@ -57,7 +64,14 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
/// `artboard`, as the Artboards panel shows it in the document the step runs in: past that
/// document's last artboard, the last one. A step that resizes the active artboard
/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`.
fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Value]) -> Result<Vec<Value>> {
/// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them.
fn run_steps(
s: &mut Session,
label: &str,
artboard: Option<u64>,
cmds: &mut [Value],
grouped: &mut Vec<(Vec<u64>, Option<usize>)>,
) -> Result<Vec<Value>> {
let mut results = Vec::with_capacity(cmds.len());
for c in cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string();
@@ -81,8 +95,12 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
{
o.insert(key.into(), json!(usize::try_from(a).map_or(last, |a| a.min(last))));
}
// The documents with an undo group open, held so that the step's first edit in one copies it.
let start = s.active().map(|d| d.uid);
let held: Vec<(u64, Arc<Document>)> = s.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, st.doc.clone())).collect();
let (v, noted) =
s.execute_step(&id, &params).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
grouped.push(step_groups(s, start, &held));
// The step's journal entry records what it took from the preferences or the clock (a
// save's date, a new document's), as a command of its own would.
if noted != params
@@ -95,6 +113,30 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
Ok(results)
}
/// The undo groups a step belongs to, as [`Session::execute`] notes a command's journal entry in
/// them: the group of each document in `held` (the grouped documents before the step) that the step
/// changed, each group it opened and edited, and the group of the document with [`DocState::uid`]
/// `start` when it started there and ended there. With the index of the document it left active
/// when it changed the active document.
fn step_groups(s: &Session, start: Option<u64>, held: &[(u64, Arc<Document>)]) -> (Vec<u64>, Option<usize>) {
let end = s.active().map(|d| d.uid);
let uids = s
.docs
.iter()
.filter(|st| {
st.undo_group.as_ref().is_some_and(|g| {
let edited = match held.iter().find(|(uid, _)| *uid == st.uid) {
Some((_, d)) => !Arc::ptr_eq(d, &st.doc),
None => g.first.is_some(),
};
edited || (start == Some(st.uid) && end == start)
})
})
.map(|st| st.uid)
.collect();
(uids, if end == start { None } else { s.active })
}
/// The documents as they were before the batch (`start`), whatever the steps did to them: opened
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
+5 -5
View File
@@ -1,7 +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 (with a central file of the whole
//! document when there are several), the preview and thumbnail rendered,
//! document when there are several and art shows), the preview and thumbnail rendered,
//! and the native document embedded for reopening.
use std::borrow::Cow;
@@ -29,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; 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}",
"{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; none when no art shows) | false (one file, the bounding box the visible art, y up from the first artboard's bottom-left corner, even when artboards are named; the default when none are named), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (the artboards written; naming them without useArtboards works as useArtboards: true)} → {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
)]
@@ -44,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, 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)",
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 (none when no art shows); absent: the same when artboard, artboards or range names artboards, else one file of the visible art; false: one file of the visible art, even when artboards are named",
},
FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" },
FormatOption {
@@ -257,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 (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.
/// artboard (after a central file of the whole document when there are several and art shows).
/// 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;
+14 -3
View File
@@ -335,7 +335,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
doc.title = title;
}
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
@@ -515,8 +515,8 @@ pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode,
/// counted as RGBA (4 bytes a pixel) or as the decoder gives them when that is more (its header
/// says so; the pixels aren't read): Open, Place and the Links panel say it (#1479). `None` for
/// images within the allowance, and for bytes that aren't an image.
/// says so; the pixels aren't read): Open, Place and the Links panel say it ([`size_note`], #1479).
/// `None` for images within the allowance, and for bytes that aren't an image.
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
use image::ImageDecoder as _;
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
@@ -530,6 +530,17 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
(rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
}
/// What Open, Place and the Links panel say of encoded image `bytes` that [`too_large_note`] is
/// said of: that the image is shown at reduced size when the renderer decodes it smaller
/// ([`vectorcraft_render::reduced_size`]), nothing when the renderer reads it at its own size a
/// row at a time, otherwise that note. Returns `None` when [`too_large_note`] returns `None`.
pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
let note = too_large_note(bytes)?;
let Some(drawn) = vectorcraft_render::reduced_size(bytes) else { return Some(note) };
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
(drawn != (w, h)).then(|| format!("an image of {w} × {h} pixels is shown at reduced size"))
}
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
+3 -3
View File
@@ -32,7 +32,7 @@ mod export;
pub(crate) mod imagemap;
mod legacy_ai;
mod load;
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
mod metafile;
mod naming;
mod native;
@@ -74,7 +74,7 @@ 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, reads_option as save_reads_option,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option,
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
templates_folder,
};
@@ -129,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, 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",
"{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 and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
+23 -1
View File
@@ -185,6 +185,14 @@ fn parent_folder(path: &str) -> Option<String> {
std::path::Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
}
/// A file dialog's starting folder for a path on disk (#4122): the path's parent when it is a
/// folder, else the nearest ancestor directory that exists.
pub fn dialog_folder(path: &str) -> Option<String> {
let folder = parent_folder(path)?;
let path = std::path::PathBuf::from(folder);
path.ancestors().find(|a| a.is_dir()).map(|a| a.to_string_lossy().into_owned())
}
/// A save format by id or extension, or why it isn't one.
fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> {
match format(f) {
@@ -300,7 +308,7 @@ fn disagreeing_option(p: &Value, svg: bool) -> std::result::Result<Option<(Strin
.as_object()
.into_iter()
.flatten()
.filter(|(k, v)| !v.is_null() && !matches!(k.as_str(), "path" | "format" | "options") && !(svg && k.as_str() == "svg"))
.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 {
@@ -1020,6 +1028,20 @@ mod tests_folders {
assert_eq!(super::xdg_documents("XDG_MUSIC_DIR=\"$HOME/Musica\"", home), None);
}
/// #4122: File → Open starts in the folder of the last opened file.
#[test]
fn dialog_folder_is_the_nearest_existing_parent() {
let base = std::env::temp_dir().join(format!("vc-open-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let file = base.join("poster.svg");
assert_eq!(super::dialog_folder(&file.to_string_lossy()).map(PathBuf::from), Some(base.clone()));
let missing = base.join("gone").join("card.ai");
assert_eq!(super::dialog_folder(&missing.to_string_lossy()).map(PathBuf::from), Some(base.clone()));
assert!(super::dialog_folder("poster.svg").is_none());
let _ = std::fs::remove_dir_all(&base);
}
/// #703: the Templates folder a dialog starts in exists: made when missing, else the nearest
/// folder above it.
#[test]
+45 -6
View File
@@ -46,7 +46,14 @@ impl ScreenSize {
/// `2x` (or `2`), `100w`, `100h` or `72ppi`, in any case; positive numbers only.
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim().to_ascii_lowercase();
let num = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0);
// Digits and `.` only: `str::parse` would also take `+2`, `1e2`, `inf` and `nan`.
let num = |t: &str| {
let t = t.trim();
if !t.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
return None;
}
t.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0)
};
if let Some(n) = s.strip_suffix("ppi") {
return num(n).map(Self::Ppi);
}
@@ -164,11 +171,14 @@ pub(super) fn file_part(s: &str) -> String {
/// trailing periods and spaces, and with `-` after a Windows device name that is the whole name or
/// comes before its first period: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` and `LPT1` to `LPT9`,
/// also with `¹`, `²` or `³` for the digit, in any case (`CON.png` becomes `CON-.png`). The same on
/// every platform.
/// every platform. Spaces between the device name and the period are dropped (`CON .png` becomes
/// `CON-.png`).
pub(crate) fn safe_file_name(s: &str) -> String {
let name = file_part(s);
let name = name.trim_end_matches(['.', ' ']);
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
// Windows ignores spaces before the extension, so `CON .png` is the CON device too.
let base = base.trim_end_matches(' ');
let upper = base.to_ascii_uppercase();
let numbered = |n: &str| n.chars().count() == 1 && n.chars().all(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'));
let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
@@ -382,7 +392,7 @@ struct Naming {
}
impl Naming {
/// The naming of params `o` for a document titled `title`.
/// The naming of params `o` for a document named `title`: its file's path, or its title before it is saved.
fn of(o: &ScreenParams, title: &str) -> Result<Self> {
let folders = Folders::of(o)?;
// A prefix names files, not folders.
@@ -669,7 +679,9 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
o.one_source()?;
let naming = Naming::of(&o, &s.doc()?.doc.title)?;
// `<filename>`: the document's file, as Save names it, or its title before it is saved.
let st = s.doc()?;
let naming = Naming::of(&o, st.path.as_deref().unwrap_or(&st.doc.title))?;
let selection = o.selection_mode()?;
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
let doc = s.doc()?.doc.clone();
@@ -846,13 +858,14 @@ pub fn first_file(s: &Session, p: &Value) -> Result<String> {
let rows = rows_of(s, &p, o.preset.as_deref())?;
o.one_source()?;
let selection = o.selection_mode()?;
let doc = &s.doc()?.doc;
let st = s.doc()?;
let doc = &st.doc;
let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
// A file per object: objects that draw nothing write no file.
if selection == Some(true) {
selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect();
}
let naming = Naming::of(&o, &doc.title)?;
let naming = Naming::of(&o, st.path.as_deref().unwrap_or(&doc.title))?;
let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?;
let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) };
let (sub, file) = naming.file(item, 0, 0, r);
@@ -995,3 +1008,29 @@ pub(crate) fn store_settings(s: &mut Session, settings: Map<String, Value>) -> R
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "settings": s.doc()?.doc.export_settings }))
}
#[cfg(test)]
mod tests {
use super::{ScreenSize, safe_file_name};
#[test]
fn size_parse_refuses_signs_and_exponents() {
for s in ["+2x", "+2", "+100w", "+100h", "+72ppi", "-2x", "1e2", "2e0w"] {
assert_eq!(ScreenSize::parse(s), None, "{s}");
}
assert_eq!(ScreenSize::parse("2x"), Some(ScreenSize::Scale(2.0)));
assert_eq!(ScreenSize::parse("1.5"), Some(ScreenSize::Scale(1.5)));
assert_eq!(ScreenSize::parse("100W"), Some(ScreenSize::Width(100.0)));
assert_eq!(ScreenSize::parse("72ppi"), Some(ScreenSize::Ppi(72.0)));
}
#[test]
fn device_name_with_space_before_extension_is_protected() {
assert_eq!(safe_file_name("CON.png"), "CON-.png");
assert_eq!(safe_file_name("CON .png"), "CON-.png");
assert_eq!(safe_file_name("NUL .png"), "NUL-.png");
assert_eq!(safe_file_name("COM1 .png"), "COM1-.png");
assert_eq!(safe_file_name("lpt9 .png"), "lpt9-.png");
assert_eq!(safe_file_name("My CON .png"), "My CON .png");
}
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
file[102..106].copy_from_slice(&1u32.to_le_bytes());
let e = raster_image(&file).err().expect("refused");
assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}");
assert!(too_large_note(&file).is_some());
assert!(load::too_large_note(&file).is_some());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
}
@@ -102,6 +102,10 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap();
assert!(r.get("files").is_none());
assert!(postscript(&b64(&r)).contains(&format!("[1 0 0 -1 {} 100] cm", -second)));
// useArtboards: false writes one file of the visible art, even with artboards named.
let r = s.execute("document.exportEps", &json!({"useArtboards": false, "artboards": [0, 1], "previewFormat": "none"})).unwrap();
assert!(r.get("files").is_none());
assert_eq!(dsc(&postscript(&b64(&r)), "BoundingBox").as_deref(), Some(format!("10 60 {} 90", second + 40.0).as_str()));
assert!(s.execute("document.exportEps", &json!({"artboard": 5})).is_err());
// Without art there is nothing to bound.
let mut empty = session(100.0, 100.0, 1);
@@ -110,6 +114,9 @@ fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
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}");
// Named artboards without useArtboards get the same files.
let r = empty.execute("document.exportEps", &json!({"range": "1-2", "previewFormat": "none"})).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
}
#[test]
@@ -205,6 +205,39 @@ fn the_first_file_is_the_file_the_export_writes_first() {
}
}
/// A saved document's `<filename>`, Full Document file and ZIP are named after its file without the
/// extension, as Save names it, whatever its File Info title says.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_saved_document_exports_under_its_file_name() {
let mut s = session(1);
s.execute("file.info", &json!({"title": "Logo: final version"})).unwrap();
let dir = std::env::temp_dir().join(format!("vc-screens-filename-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
s.execute("document.save", &json!({"path": dir.join("my-logo.vectorcraft").to_string_lossy()})).unwrap();
for (p, name) in [
(json!({"name": "<filename>__<name>", "formats": [{"format": "png"}]}), "my-logo__Artboard 1.png"),
(json!({"fullDocument": true, "formats": [{"format": "png"}]}), "my-logo.png"),
] {
assert_eq!(names(&export(&mut s, p.clone())), [name], "{p}");
assert_eq!(super::screens::first_file(&s, &p).unwrap(), name, "{p}");
}
assert_eq!(export(&mut s, json!({"zip": true, "formats": [{"format": "svg"}]}))["name"], "my-logo.zip");
let _ = std::fs::remove_dir_all(dir);
}
/// A document that has not been saved has no file: `<filename>` is its title.
#[test]
fn an_unsaved_document_exports_under_its_title() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "Poster"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
s.execute("file.info", &json!({"title": "Spring poster"})).unwrap();
let p = json!({"name": "<filename>", "formats": [{"format": "png"}]});
assert_eq!(names(&export(&mut s, p.clone())), ["Spring-poster.png"]);
assert_eq!(super::screens::first_file(&s, &p).unwrap(), "Spring-poster.png");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn sub_folders_are_made_in_the_export_folder() {
+49 -12
View File
@@ -138,6 +138,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
artboard_delete
),
cmd!(
"artboard.deleteEmpty",
"Delete Empty Artboards",
["Window", "Artboards"],
None,
"{} delete every artboard with no artwork on it (no object, hidden, locked and template art included, whose visual bounds meet or touch it, clip groups and clipping layers by their members inside the clip; guides don't count), with its guides, in one step; locked artboards stay; when every artboard is empty and none is locked, artboard 1 stays (with a locked one left, artboard 1 can go) → {deleted: [index…]} (the indices before deleting)",
has_empty_artboards,
artboard_delete_empty
),
cmd!(
"artboard.setProps",
"Artboard Options…",
@@ -863,21 +872,49 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
s.edit("Delete Artboard", |d, sel| {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let id = d.artboards.remove(i).id;
d.retire_artboard_id(id);
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
Ok(())
})?;
s.edit("Delete Artboard", |d, sel| remove_artboards(d, sel, &indices))?;
ok()
}
/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left.
fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let id = d.artboards.remove(i).id;
d.retire_artboard_id(id);
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
Ok(())
}
/// The artboards Delete Empty Artboards deletes: those with no artwork
/// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one
/// of them (a document keeps one, as Delete Artboards keeps artboard 1).
pub(crate) fn empty_artboards(d: &Document) -> Vec<usize> {
let mut empty: Vec<usize> = d.artboards.iter().enumerate().filter(|(_, a)| !a.locked && !d.artboard_has_art(a.rect)).map(|(i, _)| i).collect();
if empty.len() == d.artboards.len() {
empty.retain(|&i| i != 0);
}
empty
}
fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if empty_artboards(&st.doc).is_empty() { Err("no empty artboards to delete".into()) } else { Ok(()) }
}
fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result<Value> {
let indices = empty_artboards(&s.doc()?.doc);
if indices.is_empty() {
return Err(EngineError::Other("no empty artboards to delete".into()));
}
s.edit("Delete Empty Artboards", |d, sel| remove_artboards(d, sel, &indices))?;
Ok(json!({ "deleted": indices }))
}
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock,
/// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel
/// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when
+2 -2
View File
@@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Links",
[],
None,
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image too large to draw, document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
has_doc,
list
),
@@ -1128,7 +1128,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
// Only an image that could be past the decode allowance (at 16 bytes a pixel, the
// most a decoder gives) has its header read for this.
let large = im.width as u64 * im.height as u64 > fileio::MAX_RASTER_ALLOC / 16;
if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::too_large_note(&b.bytes)) {
if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::size_note(&b.bytes)) {
row["note"] = json!(note);
}
(im.link.as_ref(), row)
+4 -1
View File
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
has_selection,
rasterize
),
@@ -561,8 +561,11 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "nothing selected"));
}
let id = s.edit("Rasterize", |d, sel| {
let threaded = super::threads::frames(d);
let node = image.into_node(d);
let id = d.wrap(&roots, |_| node)?;
// Rasterized frames of threaded text leave their threads with the text they show.
super::threads::leave_threads(d, &threaded);
sel.set([id]);
Ok(id)
})?;
+5
View File
@@ -3,6 +3,7 @@
pub(crate) mod appearance;
mod assets;
mod attributes;
pub mod brushlib;
mod brushsym;
mod buildcmds;
pub mod clipboard;
@@ -271,6 +272,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(flatten::specs());
v.extend(stylelib::specs());
v.extend(symbollib::specs());
v.extend(brushlib::specs());
v.extend(library::specs());
v.extend(expand::specs());
v.extend(attributes::specs());
@@ -564,6 +566,9 @@ pub use opacitymask::TransparencyInfo;
/// Eyedropper Options (`Prefs::eyedropper`).
pub use xform::{AppearanceAttrs, EyedropperAttrs, EyedropperOptions, FillAttrs, StrokeAttrs};
/// Pathfinder Options (`Prefs::pathfinder`).
pub use pathops::{PATHFINDER_PRECISION_RANGE, PathfinderOptions};
/// The Attributes panel's state ([`Session::attributes_info`]).
pub use overprint::AttributesInfo;
+1 -1
View File
@@ -1804,7 +1804,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
match bounds_transform(s, p, selected_roots(s)?, 0.0)? {
match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? {
Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b),
Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false),
}
+1 -1
View File
@@ -179,7 +179,7 @@ impl<'a> Collector<'a> {
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
/// copy of `source` finds it only by its name, beside it.
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
if Path::new(copy).file_name().and_then(|n| n.to_str()) != Some(name) {
if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) {
let message = format!(
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
);
+14 -7
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({ "fillActive": s.fill_active }))
}
),
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| {
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}),
@@ -101,7 +101,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Apply Last Color",
[],
Some(","),
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
has_doc,
|s, p| {
let paint = Paint::solid(s.last_solid);
@@ -113,7 +113,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Apply Last Gradient",
[],
Some("."),
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
has_doc,
|s, p| {
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
@@ -419,7 +419,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result
/// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see
/// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and
/// remember the paint.
/// remember the paint. Replies `{objects}`: how many objects took the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
let focus = switch_or(p, "focus", true, cmd)?;
@@ -452,7 +452,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
s.fill_active = fill;
}
s.remember_paint(&paint);
return ok();
return Ok(json!({"objects": 1}));
}
// Resolve the requested targets before mutating defaults or the active proxy.
let item = item_target(s, p, cmd)?.of_kind(s, fill);
@@ -504,6 +504,13 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
let new_paint = keep_pattern_xf(&paint, p, n.appearance.paint_at(index, fill));
let placed = place_paint(&new_paint, p, item_paint_bounds(n, index, fill));
n.appearance.set_paint_at(index, fill, placed);
if !fill
&& paint.is_none()
&& let Some(stroke) = n.appearance.stroke_at_mut(index)
{
// A stroke without paint has no weight; painting it again restores the 1 pt default.
stroke.width = 0.0;
}
Ok(())
},
|d, changed| {
@@ -535,7 +542,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
s.fill_active = fill;
}
s.remember_paint(&paint);
ok()
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
}
/// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets
+362 -1
View File
@@ -2,7 +2,7 @@
//! Character/Paragraph attributes.
use serde_json::{Value, json};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun};
use vectorcraft_geom::{Rect, Vec2};
use super::*;
@@ -47,6 +47,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_format
),
cmd!(
"text.convertListToText",
"Convert to Text",
[],
None,
"{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them; in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}",
has_doc,
convert_list_to_text
),
]
}
@@ -642,3 +651,355 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame,
/// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it
/// and the numbered paragraphs the frame counts before it.
struct ParagraphPart {
frame: usize,
index: usize,
bytes: std::ops::Range<usize>,
list: Option<vectorcraft_doc::text::ParaList>,
marker: Option<String>,
count: u32,
}
/// A paragraph of a story: its parts in thread order (a later frame's first paragraph goes on
/// with the paragraph the frame before it ends in, which may be an empty part after a paragraph
/// break), and whether the call converts it (a part of it is in a paragraph the call names).
struct StoryParagraph {
parts: Vec<ParagraphPart>,
converts: bool,
}
impl StoryParagraph {
/// The part whose style a re-flowed thread gives the whole paragraph
/// (`vectorcraft_text::thread::story_paras`) and whose marker the layout draws: the first part
/// with text, else the last part.
fn main_part(&self) -> Option<&ParagraphPart> {
self.parts.iter().find(|p| !p.bytes.is_empty()).or(self.parts.last())
}
}
/// The style of the character at byte `b` of `t`, as the layout reads it: the run that holds `b`,
/// else the one before it, else the first.
fn char_style_at(t: &vectorcraft_doc::TextObject, b: usize) -> vectorcraft_doc::CharStyle {
let mut runs = vec![];
let mut off = 0;
for r in &t.runs {
runs.push((off..off + r.text.len(), &r.style));
off += r.text.len();
}
let find = |b: usize| runs.iter().find(|(r, _)| r.contains(&b)).map(|(_, s)| (*s).clone());
find(b).or_else(|| b.checked_sub(1).and_then(find)).unwrap_or_else(|| t.first_style())
}
/// The list marker that `lay` (the layout of `t`) draws before paragraph `index` (bytes `bytes`):
/// the width of its characters and the width of the tab after them. A paragraph the layout leaves
/// out (overset text) is laid out on its own, as a paragraph that starts there, numbered on from
/// `count`.
fn marker_extent(
t: &vectorcraft_doc::TextObject,
lay: &vectorcraft_text::TextLayout,
index: usize,
bytes: std::ops::Range<usize>,
count: u32,
) -> (f64, f64) {
let width = |lay: &vectorcraft_text::TextLayout, start: usize| {
lay.markers.iter().find(|m| lay.glyphs.get(m.glyphs.start).is_some_and(|g| g.byte == start)).map(|m| {
let advance = |g: usize| lay.glyphs.get(g).map_or(0.0, |g| g.advance);
(m.glyphs.clone().filter(|&g| g != m.tab).map(advance).sum::<f64>(), advance(m.tab))
})
};
width(lay, bytes.start).unwrap_or_else(|| {
let mut probe = t.clone();
probe.kind = vectorcraft_doc::TextKind::Point;
probe.runs = vectorcraft_text::edit::slice_runs(&t.runs, bytes.start, bytes.end);
probe.ruby.clear();
probe.set_all_paras(t.para_at(index).clone());
probe.list_count = count;
probe.first_para_continues = false;
width(&vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &probe), 0).unwrap_or((0.0, 0.0))
})
}
/// Whether tab stop `tab` is at position `x`.
fn stop_at(tab: &vectorcraft_doc::TabStop, x: f64) -> bool {
(tab.position - x).abs() <= 1e-6
}
/// Whether `tabs` hold one stop at position `x`, left-aligned and without a leader.
fn plain_stop_at(tabs: &[vectorcraft_doc::TabStop], x: f64) -> bool {
let there: Vec<&vectorcraft_doc::TabStop> = tabs.iter().filter(|tab| stop_at(tab, x)).collect();
matches!(there.as_slice(), [only] if only.align == vectorcraft_doc::TabAlign::Left && only.leader.is_empty())
}
/// Whether tab stop `tab` lies between positions `from` and `x` and is not left-aligned.
fn aligned_between(tab: &vectorcraft_doc::TabStop, from: f64, x: f64) -> bool {
tab.align != vectorcraft_doc::TabAlign::Left && tab.position > from + 1e-6 && tab.position < x - 1e-6
}
/// The frames of the story that type object `id` belongs to (its thread in thread order, else `id`
/// alone), each with the length of its text in bytes.
fn story_frames(s: &Session, id: NodeId) -> Vec<(NodeId, usize)> {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
let frames = d.text_threads.iter().find(|thread| thread.contains(&id)).cloned().unwrap_or_else(|| vec![id]);
frames
.into_iter()
.filter_map(|f| match d.node(f).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some((f, t.plain_text().len())),
_ => None,
})
.collect()
}
/// After Convert to Text inserted `inserted` (each a frame, the byte it went in at and its length),
/// the Type tool's anchor and caret in frame `id` (`editing`, read before the call, in a story
/// whose frames had the lengths `before`) move past the characters inserted at or before them in
/// the story. In threaded type the caret stays in the frame it was in while its place is still
/// there, and else goes to the frame its character moved to, with the anchor in that frame; that
/// frame becomes the selection when it was not selected.
fn move_editing_selection(
s: &mut Session,
(id, anchor, caret): (NodeId, usize, usize),
before: &[(NodeId, usize)],
inserted: &[(NodeId, usize, usize)],
) -> Result<()> {
// Story positions: the bytes of the frames before a frame, then the byte in it.
let start_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().take_while(|(g, _)| *g != f).map(|(_, n)| n).sum::<usize>();
let len_of = |frames: &[(NodeId, usize)], f: NodeId| frames.iter().find(|(g, _)| *g == f).map_or(0, |(_, n)| *n);
let inserted: Vec<(usize, usize)> =
inserted.iter().filter(|(f, _, _)| before.iter().any(|(g, _)| g == f)).map(|&(f, at, n)| (start_of(before, f) + at, n)).collect();
if inserted.is_empty() {
return Ok(());
}
let story = |at: usize| {
let at = start_of(before, id) + at.min(len_of(before, id));
at + inserted.iter().filter(|(b, _)| *b <= at).map(|(_, n)| n).sum::<usize>()
};
let (anchor, caret) = (story(anchor), story(caret));
let after = story_frames(s, id);
let fits = |f: NodeId| (start_of(&after, f)..=start_of(&after, f) + len_of(&after, f)).contains(&caret);
let frame = if fits(id) { Some(id) } else { after.iter().map(|(f, _)| *f).find(|f| fits(*f)) };
let Some(frame) = frame else { return Ok(()) };
let (start, len) = (start_of(&after, frame), len_of(&after, frame));
let local = |at: usize| at.saturating_sub(start).min(len);
if frame == id {
s.set_tool_option("select", &json!({"start": local(anchor), "end": local(caret)}));
return Ok(());
}
s.set_tool_option("textSelection", &json!({"editing": frame.0, "anchor": local(anchor), "caret": local(caret)}));
// The Type tool ends editing after a command when the frame it edits is not selected, itself
// or through a selected group, so that frame becomes the selection, as a click in it does.
let selected = s.active().is_some_and(|st| {
let mut cur = Some(frame);
while let Some(c) = cur {
if st.selection.contains(c) {
return true;
}
cur = st.doc.parent_of(c);
}
false
});
if !selected {
s.select(|_, sel| sel.set([frame]))?;
}
Ok(())
}
/// Type › Bullets and Numbering › Convert to Text (`text.convertListToText`): the markers of the
/// list paragraphs the call names become characters of the text, and the lists are turned off.
/// While the Type tool edits, its typing ends first and its anchor and caret move past the
/// characters inserted at or before them ([`move_editing_selection`]).
fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.convertListToText";
use vectorcraft_doc::text::ParaList;
let ids = super::typecmd::text_targets(s, p, C)?;
let range = super::typecmd::TextRange::parse(p, C)?;
let editing = match super::textedit::editing_range(s)? {
Some((id, _, _)) => {
let options = s.tool_options();
let at = |k: &str| options.get(k).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok());
at("anchor").zip(at("caret")).map(|(anchor, caret)| (id, anchor, caret))
}
None => None,
};
let story_before = editing.map(|(id, _, _)| story_frames(s, id)).unwrap_or_default();
let d = &s.doc()?.doc;
let text = |id: NodeId| match d.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
_ => None,
};
// Each story once: the frames of a thread in thread order, or one type object.
let mut stories: Vec<Vec<NodeId>> = vec![];
for id in &ids {
if stories.iter().any(|story| story.contains(id)) {
continue;
}
let thread = d.text_threads.iter().find(|thread| thread.contains(id));
stories.push(thread.map_or_else(|| vec![*id], |thread| thread.iter().copied().filter(|f| text(*f).is_some()).collect()));
}
// Per frame: the paragraphs whose list goes (with the style each gets) and the characters that
// go in at a byte.
type Stops = Vec<(usize, vectorcraft_doc::ParaStyle)>;
type Inserts = Vec<(usize, TextRun)>;
let mut edits: Vec<(NodeId, Stops, Inserts)> = vec![];
let mut converted = 0;
for story in &stories {
let frames: Vec<&vectorcraft_doc::TextObject> = story.iter().filter_map(|id| text(*id)).collect();
let mut paras: Vec<StoryParagraph> = vec![];
let mut named_parts = vec![];
let plains: Vec<String> = frames.iter().map(|t| t.plain_text()).collect();
for (f, ((id, t), plain)) in story.iter().zip(&frames).zip(&plains).enumerate() {
let named = if ids.contains(id) { range.map_or(0..t.paragraph_count(), |r| r.paras(t)) } else { 0..0 };
let mut count = t.list_count;
let mut start = 0;
for (k, piece) in plain.split('\n').enumerate() {
let bytes = start..start + piece.len();
start = bytes.end + 1;
let before = count;
let list = t.para_at(k).list;
let marker = ParaList::next_marker(list, piece.is_empty(), &mut count);
// A later frame's first paragraph goes on with the one the frame before it ends in.
if !(k == 0 && f > 0) || paras.is_empty() {
paras.push(StoryParagraph { parts: vec![], converts: false });
}
if let Some(para) = paras.last_mut() {
para.parts.push(ParagraphPart { frame: f, index: k, bytes, list, marker, count: before });
named_parts.push((paras.len() - 1, named.contains(&k)));
}
}
}
for (i, named) in named_parts {
if let Some(para) = paras.get_mut(i) {
let listed = para.main_part().is_some_and(|p| p.list.is_some());
para.converts |= named && listed;
}
}
// Each converted paragraph's main part, whether the paragraph holds a tab of its own, and
// the paragraph.
let main_parts: Vec<(&ParagraphPart, bool, &StoryParagraph)> = paras
.iter()
.filter(|p| p.converts)
.filter_map(|p| {
let tabs = p.parts.iter().any(|q| plains.get(q.frame).and_then(|s| s.get(q.bytes.clone())).is_some_and(|s| s.contains('\t')));
p.main_part().map(|m| (m, tabs, p))
})
.collect();
let layouts: Vec<Option<vectorcraft_text::TextLayout>> = frames
.iter()
.enumerate()
.map(|(f, t)| {
main_parts
.iter()
.any(|(p, _, _)| p.marker.is_some() && p.frame == f)
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
})
.collect();
let mut frame_edits: Vec<(Stops, Inserts)> = vec![(vec![], vec![]); frames.len()];
for (para, own_tabs, story_para) in main_parts {
converted += 1;
let mut stop = None;
let mut direction = None;
if let (Some(marker), Some(t), Some(Some(lay))) = (&para.marker, frames.get(para.frame), layouts.get(para.frame)) {
// The marker's tab went to the left indent when it was ahead of the marker and no
// explicit stop came first, as the layout sets it; otherwise to the next tab stop,
// without the stop's leader, and for a right, center or decimal stop measured with
// the whole paragraph after it, where a converted tab measures the rest of its line.
// A plain left stop where the marker's tab ended takes the converted tab there: one
// at the left indent in the first case, and in the second, where a stop there or
// past it has a leader or is not left-aligned, or a right, center or decimal stop
// lies between the marker and that place. Those right, center and decimal stops
// between the marker and that place are removed. No other tab reaches a stop before
// the left indent, nor one past it in a paragraph with no tab of its own.
let style = t.para_at(para.index);
let (width, gap) = marker_extent(t, lay, para.index, para.bytes.clone(), para.count);
let pen = style.left_indent + style.first_line_indent + width;
let indent = style.left_indent;
let before = style.tabs.iter().any(|tab| tab.position > pen + 1e-6 && tab.position < indent - 1e-6);
let end = pen + gap;
let needed = |x: f64| !plain_stop_at(&style.tabs, x) || style.tabs.iter().any(|tab| aligned_between(tab, pen, x));
if indent - pen > 1e-6 && !before {
stop = Some((pen, indent)).filter(|&(_, x)| needed(x));
} else if (end < indent - 1e-6 || !own_tabs)
&& style.tabs.iter().any(|tab| {
let past = tab.position > end - 1e-6 && (!tab.leader.is_empty() || tab.align != vectorcraft_doc::TabAlign::Left);
past || aligned_between(tab, pen, end)
})
{
stop = Some((pen, end)).filter(|&(_, x)| needed(x));
}
// A paragraph with no direction set runs the way its first strong character does.
// When the marker's characters would change that (a letter or a roman numeral
// before right-to-left text), the paragraph gets the direction it ran in.
if style.direction.is_none() {
let body = plains.get(para.frame).and_then(|s| s.get(para.bytes.clone())).unwrap_or_default();
let was = vectorcraft_text::paragraph_is_rtl(body, None);
if vectorcraft_text::paragraph_is_rtl(&format!("{marker}\t{body}"), None) != was {
direction = Some(if was { vectorcraft_doc::ParaDirection::RightToLeft } else { vectorcraft_doc::ParaDirection::LeftToRight });
}
}
let run = TextRun { text: format!("{marker}\t"), style: ParaList::marker_style(&char_style_at(t, para.bytes.start)), inline: None };
if let Some((_, inserts)) = frame_edits.get_mut(para.frame) {
inserts.push((para.bytes.start, run));
}
}
// A paragraph in several frames is changed in its main part and in its empty parts:
// re-flowing the thread gives the other parts with text the main part's style.
let Some(mut style) = frames.get(para.frame).map(|t| t.para_at(para.index).clone()) else { continue };
style.list = None;
if direction.is_some() {
style.direction = direction;
}
// A plain left stop at `x`, in place of any stop there and of the right, center and
// decimal stops between the marker's end `from` and `x`.
if let Some((from, x)) = stop {
style.tabs.retain(|tab| !stop_at(tab, x) && !aligned_between(tab, from, x));
let i = style.tabs.iter().position(|tab| tab.position > x).unwrap_or(style.tabs.len());
let tab = vectorcraft_doc::TabStop { position: x, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' };
style.tabs.insert(i, tab);
}
for part in story_para.parts.iter().filter(|q| std::ptr::eq(*q, para) || q.bytes.is_empty()) {
if let Some((stops, _)) = frame_edits.get_mut(part.frame) {
stops.push((part.index, style.clone()));
}
}
}
for (id, (stops, inserts)) in story.iter().zip(frame_edits) {
if !stops.is_empty() {
edits.push((*id, stops, inserts));
}
}
}
let changed: Vec<u64> = edits.iter().map(|(id, _, _)| id.0).collect();
let inserted: Vec<Value> = edits
.iter()
.flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| json!({"id": id.0, "at": at, "length": run.text.len()})))
.collect();
let insertions: Vec<(NodeId, usize, usize)> =
edits.iter().flat_map(|(id, _, inserts)| inserts.iter().map(move |(at, run)| (*id, *at, run.text.len()))).collect();
if !edits.is_empty() {
s.edit("Convert to Text", |d, _| {
for (id, stops, mut inserts) in edits {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
for (k, style) in stops {
t.edit_paras(Some(k..k + 1), |para| *para = style.clone());
}
// From the last paragraph to the first, so that each byte still names its place.
inserts.sort_by_key(|(at, _)| std::cmp::Reverse(*at));
for (at, run) in inserts {
let len = run.text.len();
vectorcraft_text::edit::replace_range_styled(&mut t.runs, at, at, &[run]);
t.ruby_after_replace(at, at, len);
}
super::typecmd::refresh_bounds(t);
}
Ok(())
})?;
if let Some(editing) = editing {
move_editing_selection(s, editing, &story_before, &insertions)?;
}
}
Ok(json!({ "ids": changed, "converted": converted, "inserted": inserted }))
}
+22 -3
View File
@@ -20,7 +20,7 @@
use serde_json::Value;
use super::{bad, find_command};
use super::{bad, command_specs, find_command};
use crate::Result;
/// What a params description says about the keys of the params object.
@@ -208,8 +208,12 @@ fn distance(a: &str, b: &str) -> usize {
/// The key of `keys` a misspelt `key` most likely meant: the nearest one, when it is near.
fn nearest<'a>(key: &str, keys: &[&'a str]) -> Option<&'a str> {
let near = keys.iter().map(|k| (distance(key, k), *k)).min()?;
(near.0 <= (key.chars().count() / 3).max(1)).then_some(near.1)
let len = key.chars().count();
let most = (len / 3).max(1);
// A key whose length differs by more than `most` is more than `most` edits away and isn't
// measured.
let near = keys.iter().filter(|k| k.chars().count().abs_diff(len) <= most).map(|k| (distance(key, k), *k)).min()?;
(near.0 <= most).then_some(near.1)
}
/// `params` has only keys the description `doc` of command `id` names, or the parameter error
@@ -480,6 +484,21 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
Ok(())
}
/// The command id a misspelled `id`, which names no command, most likely meant: the nearest one,
/// when it is near (as [`check_keys`] matches a misspelled key).
pub(crate) fn nearest_command(id: &str) -> Option<&'static str> {
let ids: Vec<&'static str> = command_specs().iter().map(|c| c.id).collect();
nearest(id, &ids)
}
/// The error for `id`, which names no command: with the nearest command id, when one is near.
pub(crate) fn unknown_command(id: &str) -> crate::EngineError {
match nearest_command(id) {
Some(near) => crate::EngineError::UnknownCommandNear(id.to_string(), near.to_string()),
None => crate::EngineError::UnknownCommand(id.to_string()),
}
}
impl crate::Session {
/// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in
/// this session's document (not as a step of a `command.batch`: what it edits is only there
+21 -2
View File
@@ -467,6 +467,9 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
if a == b {
return Err(bad(C, "join two different paths"));
}
let st = s.doc()?;
let picked_a = st.selection.anchors.get(&a).cloned().unwrap_or_default();
let picked_b = st.selection.anchors.get(&b).cloned().unwrap_or_default();
s.edit("Join", |d, sel| {
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
let pa = path_mut(d, a)?;
@@ -481,8 +484,24 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
// The ends asked for, else the closest pair.
let cands =
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
let (rx, ry) =
ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));
// Direct-selected end points decide which ends meet, before the closest pair does.
let end_of = |picked: &std::collections::BTreeSet<(usize, usize)>, si: usize, len: usize| {
let last_ix = len.checked_sub(1)?;
let first = picked.contains(&(si, 0));
let last = picked.contains(&(si, last_ix));
match (first, last) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
}
};
let by_selection = match (end_of(&picked_a, sa, x.anchors.len()), end_of(&picked_b, sb, y.anchors.len())) {
(Some(fa), Some(fb)) => Some((fa, !fb)),
_ => None,
};
let (rx, ry) = ends
.or(by_selection)
.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));
if rx {
x.reverse();
}
+143 -23
View File
@@ -23,90 +23,90 @@ pub fn specs() -> Vec<CommandSpec> {
"Unite",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Unite)
|s, p| run_pf(s, p, PathfinderOp::Unite)
),
cmd!(
"object.pathfinder.minusFront",
"Minus Front",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::MinusFront)
|s, p| run_pf(s, p, PathfinderOp::MinusFront)
),
cmd!(
"object.pathfinder.intersect",
"Intersect",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Intersect)
|s, p| run_pf(s, p, PathfinderOp::Intersect)
),
cmd!(
"object.pathfinder.exclude",
"Exclude",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Exclude)
|s, p| run_pf(s, p, PathfinderOp::Exclude)
),
cmd!(
"object.pathfinder.divide",
"Divide",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Divide)
|s, p| run_pf(s, p, PathfinderOp::Divide)
),
cmd!(
"object.pathfinder.trim",
"Trim",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Trim)
|s, p| run_pf(s, p, PathfinderOp::Trim)
),
cmd!(
"object.pathfinder.merge",
"Merge",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Merge)
|s, p| run_pf(s, p, PathfinderOp::Merge)
),
cmd!(
"object.pathfinder.crop",
"Crop",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_multi,
|s, _| run_pf(s, PathfinderOp::Crop)
|s, p| run_pf(s, p, PathfinderOp::Crop)
),
cmd!(
"object.pathfinder.outline",
"Outline",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::Outline)
|s, p| run_pf(s, p, PathfinderOp::Outline)
),
cmd!(
"object.pathfinder.minusBack",
"Minus Back",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}",
"{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}",
has_selection,
|s, _| run_pf(s, PathfinderOp::MinusBack)
|s, p| run_pf(s, p, PathfinderOp::MinusBack)
),
cmd!(
"object.pathfinder.trap",
@@ -117,6 +117,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
trap
),
cmd!(
"object.pathfinder.options",
"Pathfinder Options…",
["Window", "Pathfinder"],
None,
"{precision?: pt 0.001..10 (0.01; how closely the Pathfinder operations follow the paths: results are refit to within it and pieces thinner than it are dropped), removeRedundantPoints?: bool (false; the results lose the anchors that don't change their shape), removeUnpainted?: bool (false; Divide and Outline leave out the pieces of objects with no fill)} Pathfinder Options, kept in the preferences and used by every later Shape Mode and Pathfinder operation (not Trap); {} reads them → the options",
always,
pathfinder_options
),
cmd!(
"object.path.outlineStroke",
"Outline Stroke",
@@ -379,6 +388,82 @@ pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> {
// ---------- Pathfinder ----------
/// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`, the `pathfinder` preference
/// group), which every Shape Mode and Pathfinder operation of the panel uses. The defaults leave
/// the results as they were before the options existed.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PathfinderOptions {
/// How closely results follow the paths (pt): they are refit to within it and pieces thinner
/// than it are dropped ([`po::pathfinder_with`]).
pub precision: f64,
/// Results lose the anchors that don't change their shape (to within `precision`).
pub remove_redundant_points: bool,
/// Divide and Outline leave out the pieces of objects with no fill.
pub remove_unpainted: bool,
}
impl Default for PathfinderOptions {
fn default() -> Self {
Self { precision: po::DEFAULT_PRECISION, remove_redundant_points: false, remove_unpainted: false }
}
}
/// The range of Pathfinder Options › Precision (pt).
pub const PATHFINDER_PRECISION_RANGE: std::ops::RangeInclusive<f64> = 0.001..=10.0;
impl PathfinderOptions {
/// These options with those `p` names (`precision` in pt or a string with a unit), or why `p`
/// can't set them.
pub(crate) fn merged(&self, p: &Value) -> std::result::Result<Self, String> {
let given = match p {
Value::Null => return Ok(*self),
Value::Object(given) => given,
_ => return Err("`pathfinder` must be an object".into()),
};
let mut o = *self;
for (key, v) in given {
let flag = || v.as_bool().ok_or_else(|| format!("`{key}` must be true or false"));
match key.as_str() {
"precision" => {
o.precision =
len_param(p, key).filter(|v| PATHFINDER_PRECISION_RANGE.contains(v)).ok_or("precision must be from 0.001 to 10 pt")?;
}
"removeRedundantPoints" => o.remove_redundant_points = flag()?,
"removeUnpainted" => o.remove_unpainted = flag()?,
k => {
let path = "pathfinder";
return Err(format!("unknown option `{path}.{k}`"));
}
}
}
Ok(o)
}
}
fn pathfinder_options(s: &mut Session, p: &Value) -> Result<Value> {
s.prefs.pathfinder = s.prefs.pathfinder.merged(p).map_err(|e| bad("object.pathfinder.options", e))?;
Ok(json!(s.prefs.pathfinder))
}
/// The last part of the id of `op`'s command (`object.pathfinder.…`).
fn pf_id(op: PathfinderOp) -> &'static str {
match op {
PathfinderOp::Unite => "unite",
PathfinderOp::MinusFront => "minusFront",
PathfinderOp::Intersect => "intersect",
PathfinderOp::Exclude => "exclude",
PathfinderOp::Divide => "divide",
PathfinderOp::Trim => "trim",
PathfinderOp::Merge => "merge",
PathfinderOp::Crop => "crop",
PathfinderOp::Outline => "outline",
PathfinderOp::MinusBack => "minusBack",
PathfinderOp::HardMix => "hardMix",
PathfinderOp::SoftMix => "softMix",
}
}
fn pf_label(op: PathfinderOp) -> &'static str {
match op {
PathfinderOp::Unite => "Unite",
@@ -396,7 +481,25 @@ fn pf_label(op: PathfinderOp) -> &'static str {
}
}
fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
/// The Pathfinder Options an operation uses: those `p` gives, else the preferences'. They join the
/// command's journal entry, so replaying it gives the same result whatever the preferences are then.
fn pf_options(s: &mut Session, p: &Value, id: &str) -> Result<PathfinderOptions> {
let mut stored = s.prefs.pathfinder;
if !PATHFINDER_PRECISION_RANGE.contains(&stored.precision) {
// A preferences file edited by hand: the default, rather than refitting everything away.
stored.precision = po::DEFAULT_PRECISION;
}
let keys = ["precision", "removeRedundantPoints", "removeUnpainted"];
let given: serde_json::Map<String, Value> =
p.as_object().into_iter().flatten().filter(|(k, _)| keys.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
let options = stored.merged(&Value::Object(given)).map_err(|e| bad(id, e))?;
s.note_journal("precision", json!(options.precision));
s.note_journal("removeRedundantPoints", json!(options.remove_redundant_points));
s.note_journal("removeUnpainted", json!(options.remove_unpainted));
Ok(options)
}
fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result<Value> {
let roots = selected_roots(s)?;
if roots.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
@@ -404,6 +507,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
let shape_mode =
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
let label = pf_label(op);
let options = pf_options(s, p, &format!("object.pathfinder.{}", pf_id(op)))?;
let ids = s.edit(label, |d, sel| {
// Build the stack (back → front); names are separate from paint/style identity.
let mut styles: Vec<Style> = vec![];
@@ -452,7 +556,12 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
let name = n.name.clone().or_else(|| lv.iter().find_map(|l| l.name.clone()));
push(po::unite_all(&refs), FillRule::NonZero, style_of(top), name);
push(
po::try_unite_all_with(&refs, options.precision).map_err(|e| EngineError::Other(e.to_string()))?,
FillRule::NonZero,
style_of(top),
name,
);
} else {
for l in &lv {
if let Some((p, r)) = node_path(l) {
@@ -467,7 +576,18 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
if shapes.iter().any(|s| s.path.subpaths.iter().any(|sp| sp.anchors.iter().any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())))) {
return Err(EngineError::Other("paths contain invalid coordinates".into()));
}
let results = po::pathfinder(op, &shapes);
let mut results = po::pathfinder_with(op, &shapes, options.precision);
if options.remove_unpainted && matches!(op, PathfinderOp::Divide | PathfinderOp::Outline) {
// Pathfinder Options › Divide and Outline Will Remove Unpainted Artwork: the pieces
// of objects with no fill (or only hidden ones) go.
let filled = |st: &Style| st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none()));
results.retain(|r| styles.get(r.key as usize).is_some_and(filled));
}
if options.remove_redundant_points {
for r in &mut results {
r.path = po::remove_redundant_points(&r.path, options.precision);
}
}
if results.iter().all(|r| r.path.is_empty()) {
return Err(EngineError::Other(format!("{label} produced an empty result")));
}
+1 -1
View File
@@ -208,7 +208,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
let (sx, sy) = pt_per_px(img.ppi);
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
let warnings = fileio::size_note(&img.blob.bytes).into_iter().collect();
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
}
if pdf {
+31 -5
View File
@@ -193,6 +193,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool),
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
p!("fontsFolder", "Type", "Options", "Additional Fonts Folder", text),
// Units
@@ -366,6 +367,16 @@ pub const PREF_SPECS: &[PrefSpec] = &[
// The W/H link of the Transform panel, the Properties panel and the Control bar
p!("constrainProportions", "General", "Transform Panel", "Constrain Width and Height Proportions", bool),
p!("singleCornerRadius", "General", "Transform Panel", "Single Corner Radius", bool),
// The Transform panel menu: what the panel transforms (`default`: as General › Transform
// Pattern Tiles, until a choice in the menu), and symbols' registration point
p!(
"transformPanelPatterns",
"General",
"Transform Panel",
"Transform",
choice(&[("default", "Default"), ("object", "Transform Object Only"), ("pattern", "Transform Pattern Only"), ("both", "Transform Both")])
),
p!("useRegistrationPointForSymbol", "General", "Transform Panel", "Use Registration Point for Symbol", bool),
];
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
@@ -373,11 +384,11 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> {
}
/// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`) and the
/// Perspective Grid Options (`perspective.widget.options`). `prefs.get`
/// and `prefs.set` take them by key (a partial object updates what it names) and `prefs.reset`
/// without a category resets them.
pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget"];
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`), the
/// Perspective Grid Options (`perspective.widget.options`) and the Pathfinder Options
/// (`object.pathfinder.options`). `prefs.get` and `prefs.set` take them by key (a partial object
/// updates what it names) and `prefs.reset` without a category resets them.
pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinder"];
/// Validate a value for preference group `key` against `current`.
fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<Value, String> {
@@ -386,6 +397,10 @@ fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<
let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default();
cur.merged(v).map(|o| json!(o))
}
"pathfinder" => {
let cur: super::PathfinderOptions = serde_json::from_value(current.clone()).unwrap_or_default();
cur.merged(v).map(|o| json!(o))
}
"perspectiveWidget" => {
let mut merged = current.clone();
if let (Some(o), Some(p)) = (merged.as_object_mut(), v.as_object()) {
@@ -540,7 +555,18 @@ impl Session {
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
let fonts_folder_changed = p.fonts_folder != self.prefs.fonts_folder;
let hyphen_exceptions_changed = p.hyphenation_exceptions != self.prefs.hyphenation_exceptions;
let corner_radius_changed = p.corner_radius != self.prefs.corner_radius;
self.prefs = p;
if corner_radius_changed {
// General › Corner Radius is the Rounded Rectangle tool's radius (#394): a tool already
// made (active, or lent a selection tool for a drag) takes the new value.
let r = serde_json::Value::from(self.prefs.corner_radius);
for t in std::iter::once(&mut self.tool).chain(self.lender.as_mut()) {
if t.id() == "roundedRectangle" {
t.set_option("cornerRadius", &r);
}
}
}
if fonts_folder_changed {
let folder = self.prefs.fonts_folder.trim();
// A folder an automation caller chose is read without following its links out of it.
+4 -1
View File
@@ -603,7 +603,7 @@ struct Names {
/// The name of the entry holding `bytes`: from their content (128 bits of hash and the length), so
/// identical images share one entry and one written before isn't written again.
fn blob_name(bytes: &Arc<Vec<u8>>) -> String {
let mut guard = NAMES.lock().unwrap_or_else(|e| e.into_inner());
let mut guard = NAMES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let Names { by_address, live_after_sweep } = &mut *guard;
let names = by_address.get_or_insert_with(Default::default);
let address = Arc::as_ptr(bytes) as usize;
@@ -996,6 +996,9 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
let meta_bytes = store.read(&format!("{file}{META_EXT}")).unwrap_or_default();
let meta: Meta = serde_json::from_slice(&meta_bytes).unwrap_or_default();
let (mut doc, _, mut warnings) = fileio::native_file(&bytes).map_err(|e| EngineError::Other(format!("recovery copy `{file}`: {e}")))?;
// A frame whose first paragraph continues from the frame before draws it without the
// paragraph's first-line indent and list marker.
super::threads::mark_continued(&mut doc);
// The images, from the entries of the copy's area.
let from = file.split_once('/').map_or("", |(a, _)| a);
let mut blobs = vec![];
+2 -2
View File
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -258,7 +258,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
if new_art && let Some(st) = s.new_art_stroke_mut() {
set(st);
}
ok()
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
}
impl Session {
+1 -1
View File
@@ -127,7 +127,7 @@ impl SymbolLibrary {
}
impl LibraryFile for SymbolLibrary {
const EXTS: &'static [&'static str] = &[SYMBOLS_EXT];
const EXTS: &[&str] = &[SYMBOLS_EXT];
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
let doc = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?;
SymbolLibrary::from_document(&doc, &[], stem.to_string())
+54 -7
View File
@@ -22,6 +22,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
edit_range
),
cmd!(
"text.startList",
"Bullets and Numbering",
[],
None,
"{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}",
has_doc,
start_list
),
cmd!(
"text.setRangeStyle",
"Character",
@@ -220,6 +229,36 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id.0, "caret": caret}))
}
fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.startList";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
check_numbers(p, &["at"], C)?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let at = byte_param(p, "at").unwrap_or(len).min(len);
let Some((start, list)) = t.typed_list_at(at) else {
return Ok(json!({"id": id.0, "started": false, "caret": at}));
};
s.edit("Bullets and Numbering", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
t.splice_paras(start, at, "");
edit::replace_range(&mut t.runs, start, at, "");
t.ruby_after_replace(start, at, 0);
let i = t.paragraphs_in(start, start).start;
t.edit_paras(Some(i..i + 1), |para| {
para.list = Some(list);
// A paragraph with no left or first-line indent gets the hanging indent.
if para.left_indent == 0.0 && para.first_line_indent == 0.0 {
para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT;
para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT;
}
});
refresh_bounds(t);
Ok(())
})?;
Ok(json!({"id": id.0, "started": true, "caret": start}))
}
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
let t = text_ref(s, id)?;
@@ -235,6 +274,20 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"text": text, "runs": runs, "length": len}))
}
/// Keep `st`'s font style when its family has it, else take the family's closest one: a family
/// whose styles are numbered (W3, W6) has no Regular (#4128). A family that isn't installed keeps
/// the style as it is.
pub(crate) fn fit_font_style(st: &mut CharStyle) {
let db = vectorcraft_text::FontDb::global();
let styles = db.styles(&st.font_family);
if !styles.is_empty()
&& !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style))
&& let Some(face) = db.face(&st.font_family, &st.font_style)
{
st.font_style = face.style.clone();
}
}
/// Character attribute changes parsed from params (shared by range and whole-object styling).
#[derive(Default)]
pub(crate) struct CharChange {
@@ -501,13 +554,7 @@ impl CharChange {
st.font_family = f.clone();
// Keep the style when the new family has it, else its closest match.
if self.style.is_none() {
let styles = vectorcraft_text::FontDb::global().styles(f);
if !styles.is_empty()
&& !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style))
&& let Some(face) = vectorcraft_text::FontDb::global().face(f, &st.font_style)
{
st.font_style = face.style.clone();
}
fit_font_style(st);
}
}
if let Some(v) = &self.style {
+294
View File
@@ -50,6 +50,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_thread_selection,
remove
),
cmd!(
"text.thread.break",
"Break Thread",
[],
None,
"{id, port: \"in\"|\"out\"} break the thread of area type or type on a path `id` before it (its in port) or after it (its out port), as a double-click on that port does: the story flows into the frames before the break, and the frames after it are left empty (still threaded with each other when two or more)",
has_doc,
break_thread
),
cmd!(query "text.thread.info", "Threads", [], None, "{} → [[id…]] every thread in order", has_doc, |s, _| {
Ok(json!(s.doc()?.doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect::<Vec<_>>()).collect::<Vec<_>>()))
}),
@@ -329,6 +338,52 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
}
}
/// Cut each frame in `art`, a copy of objects of `d` under their ids, that is the last frame of a
/// thread with frames outside `art`, to the text it shows. When such a frame leaves its thread,
/// [`leave_threads`] puts the text it has no room for after the text of the last frame that stays.
pub(crate) fn keep_shown_text(d: &Document, art: &mut Node) {
let mut inside = vec![];
art.walk(&mut |n| inside.push(n.id));
let last: Vec<NodeId> = d
.text_threads
.iter()
.filter(|t| t.iter().any(|id| !inside.contains(id)))
.filter_map(|t| t.last().copied().filter(|id| inside.contains(id)))
.collect();
if !last.is_empty() {
cut_to_shown(art, &last);
}
}
/// Cut the area type and type on a path `ids` in `n` to the text each one shows.
fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
if ids.contains(&n.id)
&& let NodeKind::Text(t) = &mut n.kind
&& !matches!(t.kind, TextKind::Point)
{
// Split as `take_overflow` splits it: the first part is what the frame shows.
let from: &TextObject = t;
let parts = vectorcraft_text::thread::distribute_continuing(
vectorcraft_text::FontDb::global(),
&[from, from],
&from.runs,
&from.paragraph_styles(),
from.first_para_continues,
from.list_count,
);
if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) {
t.runs = head.runs.clone();
t.set_paragraph_styles(head.paras.clone());
refresh_bounds(t);
}
}
if let Some(children) = n.children_mut() {
for c in children {
cut_to_shown(std::sync::Arc::make_mut(c), ids);
}
}
}
/// The only area type or type on a path in the art at `id`.
fn kept_type(d: &Document, id: NodeId) -> Option<NodeId> {
let mut found = vec![];
@@ -602,6 +657,56 @@ fn remove(s: &mut Session, _: &Value) -> Result<Value> {
ok()
}
/// `text.thread.break`: a double-click on a threaded port. The thread is split there; the whole
/// story moves into the frames before the split, and the frames after it are emptied, staying
/// threaded with each other when two or more remain.
fn break_thread(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.thread.break";
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
let before = match p.get("port").and_then(Value::as_str) {
Some("in") => true,
Some("out") => false,
_ => return Err(bad(C, "`port` must be \"in\" or \"out\"")),
};
let d = &s.doc()?.doc;
let thread = d.text_threads.iter().find(|t| t.contains(&id)).cloned().unwrap_or_default();
let at = thread.iter().position(|x| *x == id).map(|i| if before { i } else { i + 1 });
let Some(at) = at.filter(|at| *at > 0 && *at < thread.len()) else {
return Err(bad(C, if before { "no frame comes before `id` in a thread" } else { "no frame comes after `id` in a thread" }));
};
let (head, tail) = (thread[..at].to_vec(), thread[at..].to_vec());
s.edit("Break Thread", |d, _| {
let sources: Vec<TextObject> = thread.iter().filter_map(|id| text_of(d, *id).cloned()).collect();
let mut story: Vec<TextRun> = sources.iter().flat_map(|t| t.runs.iter().cloned()).collect();
let paras = vectorcraft_text::thread::story_paras(&sources.iter().collect::<Vec<_>>(), &[]);
let end_style = story.last().map(|r| r.style.clone()).unwrap_or_default();
let last_para = paras.last().cloned().unwrap_or_default();
// Every frame but the first holds nothing, for the re-flow to share the story out among
// the first part's frames once.
for id in head.iter().skip(1).chain(&tail) {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
t.runs = vec![TextRun { text: String::new(), style: end_style.clone(), inline: None }];
t.set_all_paras(last_para.clone());
t.first_para_continues = false;
refresh_bounds(t);
}
}
if let Some(NodeKind::Text(t)) = head.first().and_then(|id| d.node_mut(*id)).map(|n| &mut n.kind) {
vectorcraft_text::edit::normalize(&mut story);
t.runs = story;
t.set_paragraph_styles(paras);
t.first_para_continues = false;
refresh_bounds(t);
}
if let Some(i) = d.text_threads.iter().position(|t| *t == thread) {
d.text_threads.splice(i..=i, [head, tail]);
}
d.text_threads.retain(|t| t.len() >= 2);
Ok(())
})?;
ok()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1010,6 +1115,68 @@ mod tests {
(s, [a, b, c])
}
fn continues(s: &Session, id: u64) -> bool {
text_of(&s.doc().unwrap().doc, NodeId(id)).is_some_and(|t| t.first_para_continues)
}
/// A double-click on a threaded port breaks the thread there: the story flows into the frames
/// before the break and the frames after it are left empty, still threaded with each other
/// when two or more. One undo step restores the thread and its text.
#[test]
fn break_splits_the_thread_and_the_story_flows_into_the_first_part() {
let story = TEXT.repeat(12);
let (mut s, [a, b, c]) = three_frames(&story, 40.0);
let d = new_id(s.execute("text.create", &json!({"x": 200, "y": 100, "text": "", "area": {"width": 150, "height": 100}})).unwrap());
s.execute("text.thread.add", &json!({"from": c, "to": d})).unwrap();
let all = [a, b, c, d];
let shown: Vec<String> = all.iter().map(|id| text(&s, *id)).collect();
assert!(shown.iter().all(|t| !t.is_empty()), "each frame shows part of the story");
let joined = |s: &Session, ids: &[u64]| ids.iter().map(|id| text(s, *id)).collect::<String>();
// (frame, port, threads after, the frames that hold the story, the frames left empty)
let cases = [
(a, "out", vec![vec![b, c, d]], vec![a], vec![b, c, d]),
(b, "in", vec![vec![b, c, d]], vec![a], vec![b, c, d]),
(b, "out", vec![vec![a, b], vec![c, d]], vec![a, b], vec![c, d]),
(d, "in", vec![vec![a, b, c]], vec![a, b, c], vec![d]),
(c, "out", vec![vec![a, b, c]], vec![a, b, c], vec![d]),
];
for (id, port, after, full, empty) in cases {
let case = format!("{port} port of {id}");
s.execute("text.thread.break", &json!({"id": id, "port": port})).unwrap();
assert_eq!(threads(&s), after, "{case}");
assert_eq!(joined(&s, &full), story, "{case}: the story flows into the first part");
assert!(empty.iter().all(|id| text(&s, *id).is_empty()), "{case}: the frames after the break are empty");
assert!(!continues(&s, empty[0]), "{case}: the first frame after the break starts a paragraph");
assert!(!continues(&s, a), "{case}: the first frame starts the story");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [all.to_vec()], "{case}: undo");
assert_eq!(all.iter().map(|id| text(&s, *id)).collect::<Vec<_>>(), shown, "{case}: undo");
}
}
#[test]
fn break_refuses_a_port_with_no_thread_and_bad_params() {
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(4), 200.0);
let lone = new_id(s.execute("text.create", &json!({"x": 400, "y": 10, "text": "Alone.", "area": {"width": 100, "height": 100}})).unwrap());
let ids = [a, b, c, lone];
let shown: Vec<String> = ids.iter().map(|id| text(&s, *id)).collect();
let before = threads(&s);
for params in [
json!({"id": a, "port": "in"}),
json!({"id": c, "port": "out"}),
json!({"id": lone, "port": "out"}),
json!({"id": lone, "port": "in"}),
json!({"id": a, "port": "up"}),
json!({"id": a}),
json!({"port": "out"}),
json!({"id": 9999, "port": "out"}),
] {
assert!(s.execute("text.thread.break", &params).is_err(), "{params}");
}
assert_eq!(threads(&s), before);
assert_eq!(ids.iter().map(|id| text(&s, *id)).collect::<Vec<_>>(), shown);
}
/// Whether the document holds area type or type on a path `id`.
fn is_frame(s: &Session, id: u64) -> bool {
text_of(&s.doc().unwrap().doc, NodeId(id)).is_some()
@@ -1176,6 +1343,50 @@ mod tests {
assert_eq!([a, b, c].map(|id| text(&s, id)), shown);
}
/// Object › Rasterize on frames of threaded text takes them out of their thread with the text
/// they show, which the image draws, and the other frames keep the text they hold. Undo threads
/// the frames again. When the last frame is rasterized, the frame before it takes the text it
/// has no room for.
#[test]
fn rasterized_frames_leave_their_thread_with_their_text() {
for clip in [false, true] {
for out in [&[0][..], &[1], &[2], &[0, 2]] {
let case = format!("clippingMask {clip} {out:?}");
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
let shown = frames.map(|id| text(&s, id));
assert_eq!(shown.concat(), TEXT.repeat(4));
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 36, "clippingMask": clip})).unwrap();
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
for id in &ids {
assert!(!is_frame(&s, *id), "{case}: frame {id} is rasterized");
}
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
}
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [frames], "{case}: undo");
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
}
}
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
let shown = [a, b, c].map(|id| text(&s, id));
let laid = {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
};
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let hidden = shown[2][end..].trim_start_matches(' ');
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 36})).unwrap();
assert_eq!(text(&s, a), shown[0]);
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "the frame before it keeps its text and takes the rest");
assert_eq!(threads(&s), [[a, b]]);
}
/// Frame `id`'s first line: whether its first paragraph continues from the frame before, where
/// its text starts from the frame's left edge, and how many list markers it starts with.
fn first_line(s: &Session, id: u64) -> (bool, f64, usize) {
@@ -1608,6 +1819,89 @@ mod tests {
assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::<Vec<_>>(), [0, 0]);
}
/// The text of each frame in symbol `name`'s art, sorted.
fn symbol_texts(s: &Session, name: &str) -> Vec<String> {
let doc = &s.doc().unwrap().doc;
let Some(sym) = doc.symbols.iter().find(|x| x.name == name) else { panic!("no symbol {name}") };
let mut texts = vec![];
sym.art.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
texts.push(t.plain_text());
}
});
texts.sort();
texts
}
/// New Symbol and Redefine Symbol on frames of threaded text replace them with an instance
/// whose art holds the text they show, and the frames leave their thread: the other frames keep
/// the text they hold, so each character is drawn once. Undo threads the frames again. When the
/// last frame goes into the symbol, the frame before it takes the text it has no room for, and
/// the symbol's art holds only the text the frame shows.
#[test]
fn frames_made_into_a_symbol_leave_their_thread_with_their_text() {
let symbol = |s: &mut Session, command: &str, ids: &[u64]| {
let name = if command == "symbol.update" {
let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
s.execute("select.set", &json!({"ids": [mark]})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
"Mark"
} else {
"Story"
};
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute(command, &json!({"name": name})).unwrap();
name
};
for command in ["symbol.new", "symbol.update"] {
for out in [&[0][..], &[1], &[2], &[0, 2]] {
let case = format!("{command} {out:?}");
let (mut s, frames) = three_frames(&TEXT.repeat(4), 200.0);
let shown = frames.map(|id| text(&s, id));
assert_eq!(shown.concat(), TEXT.repeat(4));
let ids: Vec<u64> = out.iter().map(|k| frames[*k]).collect();
let name = symbol(&mut s, command, &ids);
let rest: Vec<u64> = frames.iter().copied().filter(|id| !ids.contains(id)).collect();
for id in &ids {
assert!(!is_frame(&s, *id), "{case}: frame {id} is in the symbol");
}
let mut held: Vec<String> = out.iter().map(|k| shown[*k].clone()).collect();
held.sort();
assert_eq!(symbol_texts(&s, name), held, "{case}: the symbol's art holds the text the frames show");
for (id, before) in frames.iter().zip(&shown).filter(|(id, _)| rest.contains(id)) {
assert_eq!(text(&s, *id), *before, "{case}: frame {id} keeps its text");
}
assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{case}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(threads(&s), [frames], "{case}: undo");
assert_eq!(frames.map(|id| text(&s, id)), shown, "{case}: undo");
}
let (mut s, [a, b, c]) = three_frames(&TEXT.repeat(12), 100.0);
let shown = [a, b, c].map(|id| text(&s, id));
let laid = {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)) else { panic!("not area type") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t)
};
assert!(laid.overflow, "the last frame has more text than it shows");
let end = laid.lines.last().map_or(0, |l| l.end);
let hidden = shown[2][end..].trim_start_matches(' ');
let name = symbol(&mut s, command, &[c]);
assert_eq!(text(&s, a), shown[0], "{command}");
assert_eq!(text(&s, b), format!("{}{hidden}", shown[1]), "{command}: the frame before it keeps its text and takes the rest");
assert_eq!(threads(&s), [[a, b]], "{command}");
let held = symbol_texts(&s, name);
let held: Vec<&str> = held.iter().map(|t| t.trim_end()).collect();
assert_eq!(held, [shown[2][..end].trim_end()], "{command}: the symbol's art holds what the frame shows");
// With every frame of the thread in the symbol, the last frame keeps all its text.
let (mut s, frames) = three_frames(&TEXT.repeat(12), 100.0);
let mut shown = frames.map(|id| text(&s, id)).to_vec();
shown.sort();
let name = symbol(&mut s, command, &frames);
assert_eq!(symbol_texts(&s, name), shown, "{command}: the whole thread");
assert!(threads(&s).is_empty(), "{command}: the whole thread");
}
}
/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the
/// mask art too.
#[test]
+411 -1
View File
@@ -97,6 +97,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
reshape_area
),
cmd!(
"text.reshapePath",
"Reshape Type Path",
[],
None,
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)",
has_doc,
reshape_path
),
cmd!(
"text.fitHeadline",
"Fit Headline",
@@ -111,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
has_doc,
set_style
),
@@ -802,6 +811,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
style_chars(t, range, |st| {
if let Some(f) = &font {
st.font_family = f.clone();
// Keep the style when the new family has it, else its closest match (#4128).
if style.is_none() {
super::textedit::fit_font_style(st);
}
}
if let Some(f) = &style {
st.font_style = f.clone();
@@ -844,6 +857,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
st.font_family = f.clone();
st.font_version = None;
st.font_axes.clear();
if style.is_none() {
super::textedit::fit_font_style(st);
}
}
if let Some(f) = &style {
st.font_style = f.clone();
@@ -1066,6 +1082,37 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.reshapePath";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
if refs.is_empty() {
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
}
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
let node = s.doc()?.doc.node(id);
let on_path = matches!(node.map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }));
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
return Err(bad(C, "not type on a path outside perspective"));
}
s.edit("Reshape Type Path", |doc, _| {
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let before = t.kind.clone();
let within = |t: &TextObject| {
t.type_path().and_then(|p| p.bounds()).is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
};
if !(t.move_path_anchors(&refs, d) && within(t)) {
t.kind = before;
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
}
refresh_bounds(t);
Ok(())
})?;
ok()
}
#[cfg(test)]
mod area_tests {
use super::*;
@@ -1610,6 +1657,369 @@ mod area_tests {
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
}
/// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the
/// transform it is drawn with (in thousandths), from the top line down and left to right.
fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let milli = |v: f64| (v * 1000.0).round() as i64;
let mut v: Vec<(u32, i64, i64, [i64; 6])> = lay
.glyphs
.iter()
.filter(|g| !g.outline.elements().is_empty())
.map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y), g.xf.as_coeffs().map(milli)))
.collect();
v.sort_by_key(|&(_, x, y, _)| (y, x));
v
}
/// Type › Bullets and Numbering › Convert to Text: `text.convertListToText` writes each list
/// paragraph's marker before its text as characters in the marker's style, followed by a tab,
/// turns the list off, and gives the paragraph a plain left tab stop at the left indent where
/// the marker's tab went to it, so every glyph is drawn where it was: bullets in area type (a
/// wrapped item, an empty item, a right-aligned stop at the indent and a stop before it, both
/// with leaders), numbers narrower and wider than the hanging indent (one reaching a stop with
/// a leader past it), and point and vertical type. A paragraph with a tab of its own keeps its
/// stops past the indent. The characters can be styled and deleted, and one undo brings the
/// lists back.
#[test]
fn convert_to_text_writes_the_markers_where_they_were_drawn() {
use vectorcraft_doc::text::{Bullet, ParaList};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let markers = |s: &Session, id: u64| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.len();
let tabs =
|s: &Session, id: u64, k: usize| text(s, id).para_at(k).tabs.iter().map(|t| (t.position, t.align, t.leader.clone())).collect::<Vec<_>>();
let long = "The quick brown fox jumps over the lazy dog and runs far away into the woods beyond the hill";
let id = s
.execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\n{long}\n\nFour\nFive"), "area": {"width": 200, "height": 400}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let (four, five) = (long.len() + 6, long.len() + 11);
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "bullet": "square", "start": four, "end": four})).unwrap();
// A right-aligned stop with a leader at the fourth item's left indent, and a stop with a
// leader before the fifth item's: the bullets' tabs go to the indent and to that stop and
// draw no leader.
s.execute("text.tabs.set", &json!({"ids": [id], "start": four, "end": four, "stops": [{"position": 18, "align": "right", "leader": "."}]}))
.unwrap();
s.execute("text.tabs.set", &json!({"ids": [id], "start": five, "end": five, "stops": [{"position": 10, "leader": "."}]})).unwrap();
// The first character larger, raised and underlined: the bullet takes its size alone. The
// fourth item's first character is larger than the break before it.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "size": 20, "baselineShift": 4, "underline": true})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id, "start": four, "end": four + 1, "size": 20})).unwrap();
let listed = text(&s, id);
let before = drawn_glyphs(&s, id);
assert_eq!(markers(&s, id), 4, "the empty item has no bullet");
let reply = s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
let inserted = |at: usize| json!({"id": id, "at": at, "length": 4});
assert_eq!(reply, json!({"ids": [id], "converted": 5, "inserted": [inserted(0), inserted(4), inserted(four), inserted(five)]}));
assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was");
let t = text(&s, id);
assert_eq!(t.plain_text(), format!("\u{2022}\tOne\n\u{2022}\t{long}\n\n\u{25AA}\tFour\n\u{2022}\tFive"));
assert!((0..5).all(|k| t.para_at(k).list.is_none()), "no paragraph is in a list");
assert_eq!(markers(&s, id), 0);
let left = vectorcraft_doc::TabAlign::Left;
let plain_stop = |position| vec![(position, left, String::new())];
assert_eq!(
(tabs(&s, id, 0), tabs(&s, id, 1), tabs(&s, id, 2), tabs(&s, id, 3), tabs(&s, id, 4)),
(plain_stop(18.0), plain_stop(18.0), vec![], plain_stop(18.0), plain_stop(10.0)),
"a plain left stop at the left indent (in place of the right-aligned one), none in the empty item, and the stop the \
fifth item's tab went to without its leader"
);
let bullet = &t.runs[0];
assert_eq!(
(bullet.text.as_str(), bullet.style.size, bullet.style.baseline_shift, bullet.style.underline),
("\u{2022}\t", 20.0, 0.0, false),
"the bullet has the first character's size without its baseline shift and underline"
);
// The characters are text: they take a style and can be deleted.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "size": 30})).unwrap();
assert_eq!(text(&s, id).runs[0].style.size, 30.0);
s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 4, "insert": ""})).unwrap();
assert!(text(&s, id).plain_text().starts_with("One\n\u{2022}\t"));
for _ in 0..3 {
s.execute("edit.undo", &json!({})).unwrap();
}
let back = text(&s, id);
assert_eq!(back.para_at(3).list, Some(ParaList::bulleted(Bullet::Square)));
assert_eq!((back.runs, back.para, back.paras), (listed.runs, listed.para, listed.paras), "one undo step");
assert_eq!(drawn_glyphs(&s, id), before);
// Numbers 1 to 9 at 20 pt are narrower than the hanging indent, 10 is wider and takes the
// text to the next default stop, and 11 to its stop at 72 pt, whose leader the number's tab
// draws neither before nor after.
let items = (1..=11).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let id =
s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
let eleven = 9 * 7 + 8;
s.execute("text.tabs.set", &json!({"ids": [id], "start": eleven, "end": eleven, "stops": [{"position": 72, "leader": "."}]})).unwrap();
let before = drawn_glyphs(&s, id);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(drawn_glyphs(&s, id), before, "numbers");
let expected = (1..=11).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n");
assert_eq!(text(&s, id).plain_text(), expected);
assert_eq!((tabs(&s, id, 0), tabs(&s, id, 9), tabs(&s, id, 10)), (plain_stop(18.0), vec![], plain_stop(72.0)));
// A paragraph with a tab of its own keeps its stops past the left indent, which that tab
// can reach.
let id = s.execute("text.create", &json!({"x": 10, "y": 650, "size": 30, "text": "Item\tvalue"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
s.execute("text.tabs.set", &json!({"ids": [id], "stops": [{"position": 72, "leader": "."}, {"position": 200}]})).unwrap();
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(text(&s, id).plain_text(), "1.\tItem\tvalue");
assert_eq!(tabs(&s, id, 0), [(72.0, left, ".".to_string()), (200.0, left, String::new())]);
// A 30 pt number's tab to a right-aligned stop aligns the whole paragraph there, trailing
// space included, and a converted tab aligns the rest of its line: a plain left stop where
// the number's tab ended keeps the text in place. A right-aligned stop before the left stop
// the number's tab reached, which the wrapped paragraph's width kept the number's tab from,
// would catch the converted tab, which measures only the first line: it goes. The lines are
// compared with the Single-line Composer, as a paragraph with a tab is broken line by line.
let right = vectorcraft_doc::TabAlign::Right;
for (text_in, width, stops, kept) in [
("One ", 300, json!([{"position": 200, "align": "right"}]), vec![(200.0, right, String::new())]),
("The quick brown fox jumps over the lazy dog", 230, json!([{"position": 100, "align": "right"}, {"position": 150}]), vec![]),
] {
let id = s
.execute("text.create", &json!({"x": 10, "y": 600, "size": 30, "text": text_in, "area": {"width": width, "height": 300}}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number", "composer": "singleLine"})).unwrap();
s.execute("text.tabs.set", &json!({"ids": [id], "stops": stops})).unwrap();
let before = drawn_glyphs(&s, id);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(drawn_glyphs(&s, id), before, "{text_in:?}");
let after = tabs(&s, id, 0);
assert_eq!(
(after.first().map(|t| (t.1, t.2.as_str())), after.get(1..).map(<[_]>::to_vec)),
(Some((left, "")), Some(kept)),
"{text_in:?}"
);
}
// Overset items, which the layout leaves out, get the stop by the same rule: a 30 pt number is
// wider than the hanging indent, a 12 pt bullet is not.
for (size, list, text_after, stop) in
[(30, "number", "1.\tOne\n2.\tTwo", vec![]), (12, "bullet", "\u{2022}\tOne\n\u{2022}\tTwo", plain_stop(18.0))]
{
let id = s
.execute("text.create", &json!({"x": 10, "y": 500, "size": size, "text": "One\nTwo", "area": {"width": 200, "height": size + 10}}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": list})).unwrap();
assert_eq!(markers(&s, id), 1, "the second item is overset");
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!((text(&s, id).plain_text().as_str(), tabs(&s, id, 1)), (text_after, stop), "{size} pt");
}
// Point type and vertical type.
for vertical in [false, true] {
let id =
s.execute("text.create", &json!({"x": 400, "y": 700, "vertical": vertical, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
let before = drawn_glyphs(&s, id);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
assert_eq!(drawn_glyphs(&s, id), before, "vertical {vertical}");
assert_eq!(text(&s, id).plain_text(), "1.\tOne\n2.\tTwo");
}
// Type with no list paragraph: nothing to convert and no undo step.
let plain = s.execute("text.create", &json!({"x": 10, "y": 700, "text": "Plain"})).unwrap()["id"].as_u64().unwrap();
let steps = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("text.convertListToText", &json!({"ids": [plain]})).unwrap(), json!({"ids": [], "converted": 0, "inserted": []}));
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
/// A right-to-left list paragraph converted to text starts with the number, its period and a
/// tab, which are read first: the line draws its glyphs in the order it drew them with the
/// marker, the number at the right with its period at its left. Letters and roman numerals
/// would make the paragraph run left to right, so the paragraph gets the direction it ran in;
/// digits leave the direction unset.
#[test]
fn a_right_to_left_list_paragraph_converts_to_text_read_first() {
use vectorcraft_doc::ParaDirection;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
for (number, text, direction) in [
("arabic", "1.\tשלום\n2.\tעולם", None),
("upperLetters", "A.\tשלום\nB.\tעולם", Some(ParaDirection::RightToLeft)),
("upperRoman", "I.\tשלום\nII.\tעולם", Some(ParaDirection::RightToLeft)),
] {
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "שלום\nעולם", "area": {"width": 200, "height": 200}})).unwrap()["id"]
.as_u64()
.unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "number": number})).unwrap();
// `drawn_glyphs` lists the first line first, from left to right.
let order = |s: &Session| {
let glyphs = drawn_glyphs(s, id);
let top = glyphs.first().map_or(0, |g| g.2);
glyphs.into_iter().filter(|g| g.2 == top).map(|g| g.0).collect::<Vec<_>>()
};
let before = order(&s);
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert_eq!(t.plain_text(), text);
assert!(t.para_at(0).list.is_none() && t.para_at(1).list.is_none());
let directions = (t.para_at(0).direction, t.para_at(1).direction);
assert_eq!(order(&s), before, "{number}: the first line's glyphs from left to right");
assert_eq!(directions, (direction, direction), "{number}");
}
}
/// Convert to Text in threaded type writes the numbers the story counted through its frames,
/// and both frames draw every glyph where it was. A paragraph that continues into the next
/// frame gets its number once, where it starts, also when only the later frame is named, and
/// empty frames after a converted paragraph leave its list with it.
#[test]
fn threaded_numbers_convert_to_text_as_the_story_counted_them() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("text"),
};
let numbers = |s: &Session, id: u64| {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
};
let thread = |s: &mut Session, text: &str| {
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
(a, b)
};
// Whole paragraphs: the first frame ends with a paragraph break.
let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n"));
let first = numbers(&s, a).len();
assert!((2..10).contains(&first) && plain(&s, a).ends_with('\n'), "both frames hold items: {first}");
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
let reply = s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap();
assert_eq!(reply["converted"], 10, "each paragraph once");
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames");
let story = (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n");
assert_eq!(plain(&s, a) + &plain(&s, b), story);
assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}", n = first + 1)));
// The empty part after the first frame's last break and the second frame's first item are
// one paragraph: a caret at the end of the first frame converts that item.
let (a, b) = thread(&mut s, &(1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n"));
let end = plain(&s, a).len();
let reply = s.execute("text.convertListToText", &json!({"ids": [a], "start": end, "end": end})).unwrap();
assert_eq!(reply["converted"], 1);
assert!(plain(&s, b).starts_with(&format!("{n}.\tItem {n}\nItem", n = first + 1)), "{}", plain(&s, b));
// A paragraph split between the frames, with only the second frame named.
let long = "The quick brown fox jumps over the lazy dog. ".repeat(4);
let (a, b) = thread(&mut s, &format!("One\nTwo\n{long}\nFour"));
assert!(!plain(&s, a).ends_with('\n'), "the third paragraph goes on in the second frame");
// A paragraph with a tab is broken line by line, so the lines are compared with the
// Single-line Composer, which breaks the listed paragraph the same way.
s.execute("text.setFormat", &json!({"ids": [a, b], "composer": "singleLine"})).unwrap();
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap();
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames, with the paragraph split between them");
assert_eq!(plain(&s, a) + &plain(&s, b), format!("One\nTwo\n3.\t{long}\n4.\tFour"));
assert_eq!(numbers(&s, a), ["1.", "2."], "the paragraphs of the first frame alone stay in the list");
let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
assert!(second.para_at(0).list.is_none(), "the third paragraph's part in the second frame leaves the list too");
// One item that fits in the first of three frames: the two empty frames after it leave the
// list too, so the re-flowed thread draws no number before the converted one.
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Item", "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b, c], "list": "number"})).unwrap();
assert_eq!((plain(&s, b), plain(&s, c), numbers(&s, a)), (String::new(), String::new(), vec!["1.".to_string()]));
assert_eq!(s.execute("text.convertListToText", &json!({"ids": [a]})).unwrap()["converted"], 1);
assert_eq!(plain(&s, a), "1.\tItem");
assert_eq!(numbers(&s, a), Vec::<String>::new(), "no number drawn before the converted one");
for id in [a, b, c] {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert!(t.para_at(0).list.is_none(), "frame {id} leaves the list");
}
// An overset 30 pt number, wider than the hanging indent, in a last frame that starts
// inside a paragraph: its tab goes to the next stop, so it gets no stop at the left indent.
let a = s
.execute("text.create", &json!({"x": 10, "y": 10, "size": 30, "text": format!("{long}\nTwo"), "area": {"width": 150, "height": 80}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 80})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
assert!(second.first_para_continues && second.plain_text().ends_with("\nTwo"), "{:?}", second.plain_text());
assert_eq!(numbers(&s, b).len(), 0, "the second item is overset");
s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap();
let Some(NodeKind::Text(second)) = s.doc().unwrap().doc.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
assert!(second.plain_text().ends_with("\n2.\tTwo"), "{:?}", second.plain_text());
assert!(second.para_at(1).tabs.is_empty(), "no stop at the left indent: {:?}", second.para_at(1).tabs);
}
/// `text.convertListToText` sent while the Type tool edits (as from MCP or the control channel)
/// moves the tool's anchor and caret past the characters it inserts at or before them, so that
/// neither lands inside a bullet. In threaded type, when the text before the caret moves to the
/// frame before, the caret goes with its character into that frame, which becomes the
/// selection.
#[test]
fn convert_to_text_moves_the_type_tools_selection() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("text"),
};
let selection = |s: &Session| {
let o = s.tool_options();
(o["editing"].as_u64(), o["anchor"].as_u64(), o["caret"].as_u64())
};
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.select_tool("type", Default::default()).unwrap();
// From after the O of One to after the T of Two.
s.set_tool_option("textSelection", &json!({"editing": id, "anchor": 1, "caret": 5}));
s.execute("text.convertListToText", &json!({})).unwrap();
let text = plain(&s, id);
assert_eq!(text, "\u{2022}\tOne\n\u{2022}\tTwo");
assert_eq!(selection(&s), (Some(id), Some(5), Some(13)), "the anchor after the O, the caret after the T");
assert!(text.is_char_boundary(5) && text.is_char_boundary(13));
let long = "The quick brown fox jumps over the lazy dog. ".repeat(4);
let a = s
.execute("text.create", &json!({"x": 10, "y": 10, "text": format!("One\nTwo\n{long}\nFour"), "area": {"width": 150, "height": 60}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.select_tool("selection", Default::default()).unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
assert!(plain(&s, b).starts_with("dog. "), "the second frame starts with the dog: {:?}", plain(&s, b));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.select_tool("type", Default::default()).unwrap();
// The caret after the d of dog, at the start of the second frame.
s.set_tool_option("textSelection", &json!({"editing": b, "anchor": 1, "caret": 1}));
s.execute("text.convertListToText", &json!({"ids": [b]})).unwrap();
let first = plain(&s, a);
assert!(first.ends_with("lazy dog. "), "the dog moved to the first frame: {first:?}");
let after_d = (first.len() - "og. ".len()) as u64;
assert_eq!(selection(&s), (Some(a), Some(after_d), Some(after_d)), "the caret is after the d of dog in the first frame");
assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
}
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
#[test]
fn create_outlines_makes_a_path_of_the_bullet() {
+138 -26
View File
@@ -7,9 +7,6 @@ use vectorcraft_geom::Point;
use super::*;
/// Illustrator keeps up to 25 views per document.
pub const MAX_VIEWS: usize = 25;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -17,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New View…",
["View"],
None,
"{name?, center: [x, y], zoom: (1 = 100%), rotation?: deg} save a view (the app fills in the current one) → {name, index}",
"{name?, center: [x, y], zoom: (1 = 100%), rotation?: deg} save a view in the document (the app fills in the current one; default \"View N\"; at most 25, names up to 255 characters, each name once) → {name, index}",
has_doc,
new_view
),
@@ -26,8 +23,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Views…",
["View"],
None,
"{name, newName?, delete?: bool} rename or delete a saved view; no params → {views}",
has_doc,
"{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
has_views,
edit_views
),
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views),
@@ -66,12 +63,12 @@ fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
None => 0.0,
};
let st = s.doc()?;
if st.doc.views.len() >= MAX_VIEWS {
return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views")));
if st.doc.views.len() >= SavedView::MAX {
return Err(bad(C, format!("a document keeps at most {} views", SavedView::MAX)));
}
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => n.to_string(),
None => (1..).map(|i| format!("View {i}")).find(|n| !st.doc.views.iter().any(|v| &v.name == n)).unwrap_or_default(),
let name = match str_param(p, "name").and_then(SavedView::clean_name) {
Some(n) => n,
None => SavedView::default_name(&st.doc.views),
};
if st.doc.views.iter().any(|v| v.name == name) {
return Err(bad(C, format!("a view named `{name}` exists")));
@@ -84,26 +81,60 @@ fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name, "index": index }))
}
/// Edit Views… needs a document with at least one saved view.
fn has_views(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.doc.views.is_empty() { Err("no saved views".into()) } else { Ok(()) }
}
fn edit_views(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "view.saved.edit";
let Some(name) = str_param(p, "name") else { return list_views(s, p) };
let i = s.doc()?.doc.views.iter().position(|v| v.name == name).ok_or_else(|| bad(C, format!("no view named `{name}`")))?;
if bool_or(p, "delete", false) {
s.edit("Delete View", |d, _| {
d.views.remove(i);
let (edits, one) = match p.get("edits").and_then(Value::as_array) {
Some(list) => (list.as_slice(), false),
None if str_param(p, "name").is_some() => (std::slice::from_ref(p), true),
None => return list_views(s, p),
};
let before = s.doc()?.doc.views.clone();
// Edits name the views as they are now, so they don't depend on each other's order.
let mut after: Vec<Option<SavedView>> = before.iter().cloned().map(Some).collect();
for e in edits {
let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?;
let i = before.iter().position(|v| v.name == name).ok_or_else(|| bad(C, format!("no view named `{name}`")))?;
let Some(slot) = after.get_mut(i) else { continue };
if bool_or(e, "delete", false) {
*slot = None;
} else if let Some(v) = slot {
v.name = str_param(e, "newName").and_then(SavedView::clean_name).ok_or_else(|| bad(C, "give newName or delete"))?;
}
}
let after: Vec<SavedView> = after.into_iter().flatten().collect();
let mut names = std::collections::HashSet::with_capacity(after.len());
for v in &after {
if !names.insert(v.name.as_str()) {
return Err(bad(C, format!("a view named `{}` exists", v.name)));
}
}
// One rename or delete keeps the name of its step; several are one Edit Views step.
let deleted = after.len() < before.len();
let label = if !one {
"Edit Views"
} else if deleted {
"Delete View"
} else {
"Rename View"
};
let result = match (one, deleted) {
(true, true) => json!({ "deleted": str_param(p, "name") }),
(true, false) => json!({ "name": str_param(p, "newName").and_then(SavedView::clean_name) }),
(false, _) => json!({ "views": after }),
};
if before != after {
s.edit(label, |d, _| {
d.views = after;
Ok(())
})?;
return Ok(json!({ "deleted": name }));
}
let new = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "give newName or delete"))?.to_string();
if s.doc()?.doc.views.iter().any(|v| v.name == new) {
return Err(bad(C, format!("a view named `{new}` exists")));
}
s.edit("Rename View", |d, _| {
d.views[i].name = new.clone();
Ok(())
})?;
Ok(json!({ "name": new }))
Ok(result)
}
#[cfg(test)]
@@ -154,4 +185,85 @@ mod tests {
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
assert_eq!(vectorcraft_format::load(&bytes).unwrap().views[0].name, "Overview");
}
fn names(s: &mut Session) -> Vec<String> {
s.doc().unwrap().doc.views.iter().map(|v| v.name.clone()).collect()
}
fn session_with_views(names: &[&str]) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for (i, n) in names.iter().enumerate() {
s.execute("view.saved.new", &json!({"name": n, "center": [i, i], "zoom": 1 + i})).unwrap();
}
s
}
/// Two named views go out with the file and come back with their names, centres, zooms and
/// rotations, in order.
#[test]
fn saved_views_reopen_with_their_names_and_viewports() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("view.saved.new", &json!({"name": "Overview", "center": [306, 396], "zoom": 0.5})).unwrap();
s.execute("view.saved.new", &json!({"name": "Logo detail", "center": [40.25, -12.5], "zoom": 8, "rotation": -30})).unwrap();
let saved = s.doc().unwrap().doc.views.clone();
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
assert_eq!(back.views, saved);
assert_eq!((back.views[1].center.x, back.views[1].center.y, back.views[1].zoom, back.views[1].rotation), (40.25, -12.5, 8.0, -30.0));
}
/// Edit Views… applies its renames and deletes together, as one undo step, by the names the
/// views had before (so two can swap names); a shared or unknown name changes nothing.
#[test]
fn edit_views_applies_several_edits_as_one_step() {
let mut s = session_with_views(&["A", "B", "C"]);
let steps = s.doc().unwrap().history.undo.len();
let edits = json!({"edits": [
{"name": "A", "newName": "B"},
{"name": "B", "newName": " A "},
{"name": "C", "delete": true},
]});
let r = s.execute("view.saved.edit", &edits).unwrap();
assert_eq!(r["views"].as_array().map(Vec::len), Some(2));
assert_eq!(names(&mut s), ["B", "A"]);
// The views keep their viewports under their new names.
assert_eq!(s.doc().unwrap().doc.views[0].zoom, 1.0);
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(names(&mut s), ["A", "B", "C"]);
for bad in [
json!({"edits": [{"name": "A", "newName": "C"}]}),
json!({"edits": [{"name": "A", "newName": "X"}, {"name": "B", "newName": "X"}]}),
json!({"edits": [{"name": "A", "delete": true}, {"name": "Nope", "delete": true}]}),
json!({"edits": [{"name": "A", "newName": " "}]}),
] {
assert!(s.execute("view.saved.edit", &bad).is_err(), "accepted {bad}");
assert_eq!(names(&mut s), ["A", "B", "C"]);
}
}
/// A document keeps at most 25 views; unnamed ones take the first free "View N", and names are
/// capped like other names the user gives.
#[test]
fn a_document_keeps_at_most_25_views() {
let all: Vec<String> = (0..25).map(|i| format!("V{i}")).collect();
let mut s = session_with_views(&all.iter().map(String::as_str).collect::<Vec<_>>());
assert!(s.execute("view.saved.new", &json!({"name": "One too many", "center": [0, 0], "zoom": 1})).is_err());
assert_eq!(s.doc().unwrap().doc.views.len(), 25);
s.execute("view.saved.edit", &json!({"name": "V0", "delete": true})).unwrap();
assert_eq!(s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 1})).unwrap()["name"], "View 1");
s.execute("view.saved.edit", &json!({"name": "V1", "delete": true})).unwrap();
let long = "x".repeat(1000);
assert_eq!(s.execute("view.saved.new", &json!({"name": long, "center": [0, 0], "zoom": 1})).unwrap()["name"], "x".repeat(255));
}
/// Edit Views… is off until the document has a view.
#[test]
fn edit_views_needs_a_saved_view() {
let mut s = session_with_views(&[]);
assert!(s.execute("view.saved.edit", &json!({})).is_err());
s.execute("view.saved.new", &json!({"name": "Only", "center": [0, 0], "zoom": 1})).unwrap();
assert_eq!(s.execute("view.saved.edit", &json!({})).unwrap()["views"][0]["name"], "Only");
}
}
+5 -1
View File
@@ -45,6 +45,10 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) ->
});
if n.opacity < 1.0 {
v["opacity"] = json!(n.opacity);
// The same value in the percent scale `object.setProps`, `transparency.set` and the MCP
// drawing tools take, so a reader can pass it back as it is. Rounded to 1/100 of a
// percent so a 55 written as an f32 reads back as 55, not 55.000001.
v["opacityPct"] = json!((f64::from(n.opacity) * 10_000.0).round() / 100.0);
}
if n.blend != BlendMode::Normal {
v["blend"] = json!(n.blend.label());
@@ -347,7 +351,7 @@ impl FindFilter {
[self.name.as_ref(), self.kind.as_ref(), self.text.as_ref()].iter().all(|f| f.is_none())
&& self.within.is_none()
&& self.intersects.is_none()
&& self.ranges().iter().all(|r| r.is_empty())
&& self.ranges().iter().all(FindRange::is_empty)
&& self.anchors.is_empty()
}
+129 -18
View File
@@ -25,7 +25,6 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection};
use vectorcraft_geom::Affine;
use vectorcraft_tools::{PaintDefaults, Tool};
pub use cmd::EyedropperOptions;
pub use cmd::clipboard::Clipboard;
pub use cmd::create::constrain_click;
pub use cmd::distortcmds::perspective_click;
@@ -33,6 +32,7 @@ pub use cmd::params::{check_keys, check_params};
pub use cmd::pathops::auto_simplify_level;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use cmd::{EyedropperOptions, PathfinderOptions};
pub use journal::Journal;
pub use tooling::{UiRequest, ViewInfo};
pub use vectorcraft_doc as doc;
@@ -44,6 +44,9 @@ pub use vectorcraft_tools as tools;
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
/// An unknown command id, with the command id it most likely misspells.
#[error("unknown command `{0}`; did you mean `{1}`?")]
UnknownCommandNear(String, String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
@@ -302,10 +305,19 @@ pub struct UndoGroup {
/// group drops them. An entry that changed the active document has the index of the document it
/// left active, and a canceled group turns it into that change.
journal: Vec<(usize, Option<usize>)>,
/// For each `command.batch` entry in `journal`, the steps of its `commands` that belong to the
/// group (by number), each with the index of the document it left active when it changed the
/// active document: a canceled group drops those steps and keeps the batch's other ones.
steps: Vec<BatchSteps>,
/// The undo step's name; else the first edit's.
label: Option<String>,
}
/// A `command.batch` journal entry's number and the steps of it that belong to an undo group, each
/// with the index of the document it left active when it changed the active document
/// ([`UndoGroup::steps`]).
type BatchSteps = (usize, Vec<(usize, Option<usize>)>);
/// One undo step begun with [`Session::begin_undo_scope`], which [`Session::end_undo_scope`] ends.
#[derive(Clone, Debug)]
pub struct UndoScope {
@@ -974,6 +986,9 @@ pub struct Prefs {
pub recent_fonts_count: u32,
pub missing_glyph_protection: bool,
pub highlight_alternate_glyphs: bool,
/// Type › Highlight Substituted Fonts: on screen, text drawn in a substitute for a missing
/// font is highlighted, in every document (on by default).
pub highlight_substituted_fonts: bool,
pub placeholder_text: bool,
// Units
pub units_general: String,
@@ -1097,6 +1112,9 @@ pub struct Prefs {
/// Eyedropper Options (`eyedropper.setOptions`; a preference group, see
/// [`cmd::prefscmds::PREF_GROUPS`]).
pub eyedropper: EyedropperOptions,
/// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`; a preference group, see
/// [`cmd::prefscmds::PREF_GROUPS`]).
pub pathfinder: PathfinderOptions,
/// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored).
/// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*`
/// edit it.
@@ -1155,6 +1173,16 @@ pub struct Prefs {
pub constrain_proportions: bool,
/// One Corner Radius field sets every corner of a live rectangle (off: a field per corner).
pub single_corner_radius: bool,
/// The Transform panel menu's Transform Object Only / Transform Pattern Only / Transform Both
/// (`object`, `pattern`, `both`): what the panel's fields, flips and menu flips transform
/// (they pass `objects` and `patterns` to the transform commands). `default` (and any other
/// value), until one is chosen: they pass neither, so patterns follow General › Transform
/// Pattern Tiles (`transformPatternTiles`), as before the menu acted.
pub transform_panel_patterns: String,
/// The Transform panel menu's Use Registration Point for Symbol: with one symbol instance
/// selected, the panel and the Properties panel's Transform section measure and transform it
/// from its symbol's registration point instead of the reference point.
pub use_registration_point_for_symbol: bool,
/// The tools' persistent options by store (a tool id, or a store a family shares: `liquify`
/// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]:
/// kept across tool switches and saved with the preferences. Not a Preferences dialog field:
@@ -1231,6 +1259,7 @@ impl Default for Prefs {
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: false,
highlight_substituted_fonts: true,
placeholder_text: true,
units_general: s("points"),
units_stroke: s("points"),
@@ -1311,6 +1340,7 @@ impl Default for Prefs {
Color::rgb8(r, g, b).to_hex()
},
eyedropper: Default::default(),
pathfinder: Default::default(),
flattener_presets: vec![],
width_profiles: vec![],
japanese_crop_marks: false,
@@ -1328,6 +1358,8 @@ impl Default for Prefs {
print_presets: vec![],
constrain_proportions: false,
single_corner_radius: true,
transform_panel_patterns: "default".to_string(),
use_registration_point_for_symbol: false,
tool_settings: Default::default(),
perspective_presets: vec![],
blend_options: None,
@@ -1401,6 +1433,8 @@ pub struct Session {
pub style_libraries: cmd::stylelib::Libraries,
/// User Defined and loaded symbol libraries (Window → Symbol Libraries); not saved.
pub symbol_libraries: cmd::symbollib::Libraries,
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
pub brush_libraries: cmd::brushlib::Libraries,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
@@ -1437,6 +1471,10 @@ pub struct Session {
/// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an
/// error brings back; `None` otherwise.
pub(crate) batch_stash: Option<Vec<DocState>>,
/// After `command.batch` ran its steps, until the top-level command notes its entry in the undo
/// groups: for each step, the documents ([`DocState::uid`]) whose undo groups it belongs to, and
/// the index of the document it left active when it changed the active document.
pub(crate) batch_steps: Option<Vec<(Vec<u64>, Option<usize>)>>,
/// While [`Session::place_read`] runs `file.place`: the file read for it, which the command
/// places instead of reading the file; `None` otherwise.
pub(crate) place_hand_off: Option<cmd::place::PlaceRead>,
@@ -1556,6 +1594,7 @@ impl Session {
freeform_point: None,
style_libraries: Default::default(),
symbol_libraries: Default::default(),
brush_libraries: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
profile_dir: None,
@@ -1568,6 +1607,7 @@ impl Session {
journal_query: false,
explicit_undo_group: None,
batch_stash: None,
batch_steps: None,
place_hand_off: None,
queue_hand_off: None,
place_cursor_requests: false,
@@ -1784,7 +1824,7 @@ impl Session {
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
@@ -1825,6 +1865,7 @@ impl Session {
self.note_depth = 1;
self.journal_query = false;
self.batch_stash = None;
self.batch_steps = None;
}
// A selection-only command given `ids` (or `id`) selects them first, in the same undo step
// (#2130); when the command then fails, the selection (and the current layer selecting
@@ -2290,7 +2331,7 @@ impl Session {
if let Some(st) = self.active_mut()
&& st.undo_group.is_none()
{
st.undo_group = Some(UndoGroup { first: None, journal: vec![], label });
st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label });
}
}
@@ -2303,7 +2344,7 @@ impl Session {
let Some(label) = &self.explicit_undo_group else { return };
for st in &mut self.docs {
if !open.contains(&st.uid) && st.undo_group.is_none() {
st.undo_group = Some(UndoGroup { first: None, journal: vec![], label: label.clone() });
st.undo_group = Some(UndoGroup { first: None, journal: vec![], steps: vec![], label: label.clone() });
}
}
}
@@ -2317,11 +2358,13 @@ impl Session {
/// each document in `grouped` ([`Session::undo_group_docs`] before the entries) that they changed,
/// each group they opened and edited, and the group of the document with [`DocState::uid`] `doc`
/// when they started in it and it is still the active one. A cancel drops them. The last entry of a command that changed the
/// active document keeps the index of the document it left active.
/// active document keeps the index of the document it left active. A `command.batch` entry also
/// keeps, in each group, the steps that belong to it ([`Session::batch_steps`]).
fn journal_in_undo_groups(&mut self, doc: Option<u64>, grouped: &[(u64, *const Document)], from: usize) {
let to = self.journal.len();
let active = self.active().map(|d| d.uid);
let switched = if active == doc { None } else { self.active };
let batch = self.batch_steps.take().filter(|_| to > from && self.journal.get(to - 1).is_some_and(|(id, _)| id == "command.batch"));
for st in &mut self.docs {
let Some(g) = &mut st.undo_group else { continue };
let started = doc == Some(st.uid) && active == doc;
@@ -2335,6 +2378,11 @@ impl Session {
let noted = g.journal.partition_point(|&(n, _)| n < from);
g.journal.truncate(noted);
g.journal.extend((from..to).map(|n| (n, switched.filter(|_| n + 1 == to))));
g.steps.retain(|&(n, _)| n < from);
if let Some(steps) = &batch {
let own = steps.iter().enumerate().filter(|(_, (uids, _))| uids.contains(&st.uid));
g.steps.push((to - 1, own.map(|(i, &(_, switched))| (i, switched)).collect()));
}
}
}
}
@@ -2344,13 +2392,19 @@ impl Session {
/// documents opened after this open no undo group.
pub fn end_undo_group(&mut self, cancel: bool) {
self.explicit_undo_group = None;
self.close_undo_groups(cancel);
self.close_undo_groups(cancel, None);
}
/// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`.
fn close_undo_groups(&mut self, cancel: bool) {
/// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`, for the
/// document with [`DocState::uid`] `only` alone when given. The groups that stay open follow the
/// journal entries and batch steps of theirs that a cancel renumbers.
fn close_undo_groups(&mut self, cancel: bool, only: Option<u64>) {
let mut journal: Vec<(usize, Option<usize>)> = vec![];
let mut steps: Vec<BatchSteps> = vec![];
for st in &mut self.docs {
if only.is_some_and(|uid| uid != st.uid) {
continue;
}
let Some(g) = st.undo_group.take() else { continue };
// Only while the group's step is still the newest one.
if cancel
@@ -2366,11 +2420,21 @@ impl Session {
st.revision += 1;
// The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile.
journal.extend(g.journal);
steps.extend(g.steps);
}
}
// A batch that edited two of the documents is in both groups.
journal.sort_unstable();
journal.dedup_by_key(|&mut (n, _)| n);
// A batch with steps outside the canceled groups keeps those steps.
let mut rewritten: Vec<(usize, Vec<Option<usize>>)> = vec![];
journal.retain(|&(n, _)| match self.keep_batch_steps(n, &steps) {
Some(kept) => {
rewritten.push((n, kept));
false
}
None => true,
});
// An entry that changed the active document becomes that change, so the entries after it run
// in the same document when the journal is replayed.
for &(n, index) in &journal {
@@ -2380,21 +2444,61 @@ impl Session {
}
let gone: Vec<usize> = journal.into_iter().filter(|(_, index)| index.is_none()).map(|(n, _)| n).collect();
self.journal.remove(&gone);
let below = |n: usize| gone.partition_point(|&m| m < n);
for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) {
g.journal.retain(|(n, _)| gone.binary_search(n).is_err());
for (n, _) in &mut g.journal {
*n -= below(*n);
}
g.steps.retain(|(n, _)| gone.binary_search(n).is_err());
for (n, own) in &mut g.steps {
if let Some((_, kept)) = rewritten.iter().find(|(r, _)| r == n) {
own.retain_mut(|(i, _)| kept.get(*i).copied().flatten().map(|k| *i = k).is_some());
}
*n -= below(*n);
}
}
}
/// When journal entry `n` is a `command.batch` with `steps` noted in canceled undo groups and
/// other steps that are not `document.activate`: rewrite its `commands` without the noted steps
/// (a noted step that changed the active document becomes that change) → each step's number in
/// the rewritten `commands`, `None` for a noted step. `None` leaves the entry to be dropped as a
/// whole.
fn keep_batch_steps(&mut self, n: usize, steps: &[BatchSteps]) -> Option<Vec<Option<usize>>> {
let noted: Vec<(usize, Option<usize>)> = steps.iter().filter(|(e, _)| *e == n).flat_map(|(_, s)| s.iter().copied()).collect();
let (id, entry) = self.journal.get(n).filter(|(id, _)| id == "command.batch" && steps.iter().any(|(e, _)| *e == n))?;
let commands = entry.get("commands").and_then(Value::as_array)?;
let kept = |i: usize| !noted.iter().any(|&(s, _)| s == i);
let activate = |c: &Value| c.get("command").and_then(Value::as_str) == Some("document.activate");
if commands.iter().enumerate().all(|(i, c)| !kept(i) || activate(c)) {
return None;
}
let (mut rewritten, mut numbers) = (Vec::with_capacity(commands.len()), Vec::with_capacity(commands.len()));
for (i, c) in commands.iter().enumerate() {
if kept(i) {
numbers.push(Some(rewritten.len()));
rewritten.push(c.clone());
} else {
numbers.push(None);
if let Some(index) = noted.iter().find(|&&(s, _)| s == i).and_then(|&(_, index)| index) {
rewritten.push(serde_json::json!({ "command": "document.activate", "params": { "index": index } }));
}
}
}
let (id, mut entry) = (id.clone(), entry.clone());
if let Some(o) = entry.as_object_mut() {
o.insert("commands".into(), Value::Array(rewritten));
}
self.journal.replace(n, (id, entry));
Some(numbers)
}
/// [`Session::end_undo_group`] for the open document with [`DocState::uid`] `uid` only (it may
/// have closed since): its open undo group closes, and undo groups open in other documents stay
/// open.
fn end_document_undo_group(&mut self, uid: u64, cancel: bool) {
// The other documents' groups are set aside while `end_undo_group` closes this one's.
let others: Vec<(u64, UndoGroup)> =
self.docs.iter_mut().filter(|st| st.uid != uid).filter_map(|st| Some((st.uid, st.undo_group.take()?))).collect();
self.close_undo_groups(cancel);
for (other, g) in others {
if let Some(st) = self.document_mut(other) {
st.undo_group = Some(g);
}
}
self.close_undo_groups(cancel, Some(uid));
}
/// Make the edits that follow in the active document one undo step, until
@@ -2416,7 +2520,7 @@ impl Session {
/// closes it. A scope that joined an open group leaves the group open; with `cancel`, it puts the
/// document and its selection back as they were when the scope began and cuts the journal back to
/// where the scope began (only while the group's step is still the newest one), and an undo step
/// that the scope's first edit recorded goes too.
/// that the scope's first edit recorded goes too. The open undo groups drop the cut entries.
pub fn end_undo_scope(&mut self, scope: UndoScope, cancel: bool) {
let Some((doc, selection, first)) = scope.joined else {
self.end_document_undo_group(scope.uid, cancel);
@@ -2445,6 +2549,11 @@ impl Session {
st.history.recost_last(&st.doc);
st.revision += 1;
self.journal.truncate(scope.journal);
// The next commands take the cut entries' numbers, so no open undo group keeps them.
for g in self.docs.iter_mut().filter_map(|st| st.undo_group.as_mut()) {
g.journal.retain(|&(n, _)| n < scope.journal);
g.steps.retain(|&(n, _)| n < scope.journal);
}
}
/// Commands with enablement (for menus, palette, MCP `list_commands`).
@@ -2508,6 +2617,8 @@ mod tests_blendopts;
#[cfg(test)]
mod tests_blendspine;
#[cfg(test)]
mod tests_brushlib;
#[cfg(test)]
mod tests_brushsym;
#[cfg(test)]
mod tests_build;
+64
View File
@@ -90,6 +90,42 @@ fn unknown_and_disabled() {
assert!(matches!(s.execute("object.group", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn unknown_command_names_the_nearest_id() {
let mut s = session();
for typo in ["select.al", "Select.All", "selcet.all"] {
let e = s.execute(typo, &json!({})).unwrap_err();
assert!(matches!(&e, EngineError::UnknownCommandNear(id, near) if id == typo && near == "select.all"), "{typo}: {e:?}");
assert_eq!(e.to_string(), format!("unknown command `{typo}`; did you mean `select.all`?"));
}
let e = s.execute("nope", &json!({})).unwrap_err();
assert!(matches!(&e, EngineError::UnknownCommand(id) if id == "nope"), "{e:?}");
assert_eq!(e.to_string(), "unknown command `nope`");
let e = s.execute("command.batch", &json!({"commands": [{"command": "select.al", "params": {}}]})).unwrap_err();
assert!(e.to_string().contains("did you mean `select.all`?"), "{e}");
}
#[test]
fn a_long_unknown_command_id_is_answered_at_once() {
let mut s = session();
let long = "x".repeat(200_000);
let start = std::time::Instant::now();
assert!(matches!(s.execute(&long, &json!({})), Err(EngineError::UnknownCommand(_))));
let e = crate::check_params("file.new", &json!({ long.as_str(): 1 })).unwrap_err();
assert!(e.to_string().contains("the command takes:"), "{e}");
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn action_batch_names_the_nearest_command_id() {
let mut s = session();
let e = s.execute("action.batch", &json!({"source": "dataSets", "commands": [{"command": "select.al"}]})).unwrap_err();
assert_eq!(e.to_string(), "invalid parameters for `action.batch`: commands[0]: unknown command `select.al`; did you mean `select.all`?");
let e = s.execute("action.batch", &json!({"source": "dataSets", "commands": [{"command": "nope"}]})).unwrap_err();
assert_eq!(e.to_string(), "invalid parameters for `action.batch`: commands[0]: unknown command `nope`");
}
#[test]
fn group_ungroup() {
let mut s = session();
@@ -2451,6 +2487,34 @@ fn an_isolated_compound_paths_member_moves_alone() {
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 55.0), "the compound path selected: the whole ring");
}
/// The X, Y, W and H of the Properties and Transform panels run `object.setBounds`, which takes the
/// same objects as the other transforms: a path of a compound path selected alone sets its own
/// box and leaves the ring around it (#4046). The hole is off-centre, and a plain rectangle
/// outside the ring keeps "everything moved" from passing.
#[test]
fn set_bounds_of_a_compound_paths_member_sets_only_that_path() {
let x = |s: &Session, id: NodeId| {
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
(b.x0, b.x1)
};
let mut s = session();
let (c, outer, hole) = isolated_ring(&mut s);
let other = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
s.execute("object.setBounds", &json!({"width": 20, "reference": 0})).unwrap();
assert_eq!((x(&s, outer), x(&s, hole)), ((0.0, 40.0), (10.0, 30.0)), "isolated: only the hole is wider");
s.execute("object.setBounds", &json!({"x": 25, "reference": 0})).unwrap();
assert_eq!((x(&s, outer), x(&s, hole)), ((0.0, 40.0), (25.0, 45.0)), "isolated: only the hole moved");
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
s.execute("object.setBounds", &json!({"x": 12, "reference": 0})).unwrap();
assert_eq!((x(&s, outer), x(&s, hole)), ((0.0, 40.0), (12.0, 32.0)), "not isolated: still only the hole");
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("object.setBounds", &json!({"x": 5, "reference": 0})).unwrap();
assert_eq!((x(&s, outer), x(&s, hole)), ((5.0, 45.0), (17.0, 37.0)), "the compound path selected: the whole ring");
assert_eq!(x(&s, other), (100.0, 110.0), "the rectangle outside never moves");
}
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
/// than it has reports `childCount`, and invalid values are rejected, not reinterpreted.
#[test]
+327
View File
@@ -0,0 +1,327 @@
//! Brush libraries: the built-in libraries, saving a document's brushes as a library (User
//! Defined, or a file), reopening it and adding its brushes to another document (the patterns and
//! symbols their art uses along, one undo step, deduped, renamed on a clash, applied), other
//! documents' brushes, and files that can't be read.
use serde_json::{Value, json};
use vectorcraft_brush::BrushKind;
use vectorcraft_doc::{NodeId, NodeKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn brushes(s: &Session) -> Vec<String> {
vectorcraft_brush::library(doc(s)).into_iter().map(|b| b.name).collect()
}
fn brush(s: &Session, name: &str) -> vectorcraft_brush::Brush {
vectorcraft_brush::find(doc(s), name).unwrap_or_else(|| panic!("no brush {name}"))
}
fn strs(v: &Value) -> Vec<&str> {
v.as_array().unwrap().iter().map(|n| n.as_str().unwrap()).collect()
}
/// The patterns a brush's art fills with.
fn art_patterns(b: &vectorcraft_brush::Brush) -> Vec<String> {
let mut out = vec![];
for a in b.art() {
a.walk(&mut |n| {
for it in &n.appearance.items {
if let vectorcraft_color::Paint::Pattern { pattern, .. } = it.paint() {
out.push(pattern.clone());
}
}
});
}
out
}
/// A folder of its own under the temp folder, emptied.
fn folder(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vc-brushlib-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
/// Pattern `name` (a 4 pt dot of `color` in a 10 pt tile) with its swatch, as one step.
fn add_pattern(s: &mut Session, name: &str, color: &str) {
let color = vectorcraft_color::Color::from_hex(color).unwrap();
s.edit("Pattern", |d, _| {
let dot = vectorcraft_doc::Node::path(
d.alloc_id(),
vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0)),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(color), vectorcraft_color::Paint::None, 0.0),
);
let def = vectorcraft_doc::PatternDef {
tile: vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0),
..vectorcraft_doc::PatternDef::new(name, vec![std::sync::Arc::new(dot)])
};
d.swatches.push(vectorcraft_color::Swatch {
name: name.into(),
paint: vectorcraft_color::Paint::Pattern { pattern: name.into(), xf: Default::default() },
global: false,
spot: false,
});
d.patterns.push(def);
Ok(())
})
.unwrap();
}
/// A document with brushes of its own: "My Nib" (calligraphic) and "Dotted Band" (an art brush
/// whose art is filled with pattern "Dots").
fn kit() -> Session {
let mut s = session();
run(&mut s, "brush.new", json!({"type": "calligraphic", "name": "My Nib", "params": {"angle": 20, "roundness": 40, "size": 7}}));
add_pattern(&mut s, "Dots", "#ff0000");
let band = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 8}))["id"].as_u64().unwrap();
run(&mut s, "paint.setFill", json!({"swatch": "Dots"}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Dotted Band", "ids": [band]}));
s
}
#[test]
fn the_builtin_libraries_are_original_brushes_generated_in_code() {
let mut s = Session::new();
let list = run(&mut s, "brush.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes"]);
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
assert_eq!(
lib["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
["Notched Ribbon", "Double Line", "Wave", "Teardrop", "Striped Ribbon"]
);
assert_eq!(libs[0]["count"].as_u64(), Some(5));
// One kind of brush per library, every brush drawing something.
for (id, kind) in [("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter")] {
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
assert!(lib.len() >= 4, "{id}");
for b in &lib.brushes {
assert_eq!(b.kind.type_id(), kind, "{}", b.name);
let mut line = vectorcraft_geom::BezPath::new();
line.move_to((0.0, 0.0));
line.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0));
let stroke = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
assert!(!vectorcraft_brush::stroke_pieces(&lib.doc, b, &line, &stroke).is_empty(), "{} draws nothing", b.name);
}
}
// The resources their art uses come with them: Striped Ribbon's pattern, Sparkles' symbol.
let (_, ribbons) = cmd::brushlib::library(&s, "art-ribbons").unwrap();
assert_eq!(art_patterns(ribbons.brush("Striped Ribbon").unwrap()), ["Pinstripes"]);
assert!(ribbons.doc.pattern("Pinstripes").is_some());
let (_, shapes) = cmd::brushlib::library(&s, "scatter-shapes").unwrap();
let BrushKind::Scatter(sparkles) = &shapes.brush("Sparkles").unwrap().kind else { panic!("not a scatter brush") };
assert!(matches!(&sparkles.art.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Sparkle"));
assert!(shapes.doc.symbols.iter().any(|x| x.name == "Sparkle"));
// None of them is a default brush.
let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect();
for id in ids {
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
assert!(lib.brushes.iter().all(|b| !defaults.contains(&b.name.as_str())), "{id}");
}
assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err());
}
#[test]
fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() {
let dir = folder("user");
let mut s = kit();
s.brush_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let r = run(&mut s, "brush.saveLibrary", json!({"user": true, "name": "Inks", "names": ["My Nib", "Dotted Band"]}));
assert_eq!((r["library"].as_str(), r["count"].as_u64()), (Some("user/Inks.vectorcraft"), Some(2)));
let path = r["path"].as_str().unwrap().to_string();
let list = run(&mut s, "brush.libraries", json!({}));
let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect();
assert_eq!(user.len(), 1);
assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("Inks"), Some(2)));
// The file is a document: the two brushes and the pattern their art uses, no art.
let saved = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap();
let names: Vec<String> = vectorcraft_brush::library(&saved).into_iter().map(|b| b.name).collect();
assert_eq!(names, ["My Nib", "Dotted Band"]);
assert_eq!(saved.patterns.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Dots"]);
assert!(saved.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())));
// Loading the file gives that User Defined library.
let r = run(&mut s, "brush.loadLibrary", json!({ "path": path }));
assert_eq!((r["library"].as_str(), r["name"].as_str()), (Some("user/Inks.vectorcraft"), Some("Inks")));
// Into a new document: Dotted Band and its pattern, as one step.
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
let defaults = brushes(&s);
let before = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Inks", "name": "Dotted Band"}));
assert_eq!((strs(&r["names"]), strs(&r["added"]), strs(&r["existing"])), (vec!["Dotted Band"], vec!["Dotted Band"], vec![]));
assert_eq!(undo_len(&s), before + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Add to Brushes");
assert_eq!(brushes(&s), [defaults.clone(), vec!["Dotted Band".to_string()]].concat());
let (_, lib) = cmd::brushlib::library(&s, "Inks").unwrap();
assert_eq!(&brush(&s, "Dotted Band"), lib.brush("Dotted Band").unwrap());
assert!(doc(&s).pattern("Dots").is_some() && doc(&s).swatch("Dots").is_some(), "the pattern its art fills with");
assert_eq!(run(&mut s, "brush.list", json!({}))["current"], "Dotted Band");
// Undo takes it all back.
run(&mut s, "edit.undo", json!({}));
assert_eq!(brushes(&s), defaults);
assert!(doc(&s).pattern("Dots").is_none());
run(&mut s, "edit.redo", json!({}));
// Again: already there, so no step and no copy.
let before = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "user/Inks.vectorcraft", "names": ["Dotted Band", "My Nib", "Dotted Band"]}));
assert_eq!((strs(&r["names"]), strs(&r["existing"]), strs(&r["added"])), (vec!["Dotted Band", "My Nib"], vec!["Dotted Band"], vec!["My Nib"]));
assert_eq!(undo_len(&s), before + 1, "My Nib was new");
let before = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Inks"}));
assert_eq!((strs(&r["existing"]).len(), undo_len(&s)), (2, before));
assert_eq!(brushes(&s).len(), defaults.len() + 2);
assert_eq!(doc(&s).patterns.len(), 1);
assert_eq!(run(&mut s, "brush.list", json!({}))["current"], "My Nib", "the library's first brush");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn library_brushes_bring_their_patterns_and_symbols_rename_clashes_and_apply() {
let mut s = session();
// The document has a pattern "Pinstripes" and a brush "Wave" of its own.
add_pattern(&mut s, "Pinstripes", "#00ff00");
let mine = doc(&s).pattern("Pinstripes").unwrap().clone();
run(&mut s, "brush.new", json!({"type": "calligraphic", "name": "Wave"}));
let my_wave = brush(&s, "Wave");
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap();
run(&mut s, "select.set", json!({"ids": [path]}));
let before = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "art-ribbons", "names": ["Striped Ribbon", "Wave"], "apply": true}));
assert_eq!(undo_len(&s), before + 1, "adding and applying are one step");
assert_eq!(strs(&r["names"]), ["Striped Ribbon", "Wave 2"]);
assert_eq!(r["applied"], json!([path]));
assert_eq!(brush(&s, "Wave"), my_wave, "the document's own Wave stays");
// The library's Pinstripes comes in under a free name, and the brush's art follows it.
assert_eq!(doc(&s).pattern("Pinstripes").unwrap(), &mine);
assert!(doc(&s).pattern("Pinstripes 2").is_some() && doc(&s).swatch("Pinstripes 2").is_some());
assert_eq!(art_patterns(&brush(&s, "Striped Ribbon")), ["Pinstripes 2"]);
// The first one is applied to the selected path and is the current brush.
let n = doc(&s).node(NodeId(path)).unwrap();
assert_eq!(n.appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Striped Ribbon"));
assert_eq!(run(&mut s, "brush.list", json!({}))["current"], "Striped Ribbon");
// Adding it again finds it, its pattern renamed, and adds nothing.
let before = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "art-ribbons", "names": ["Striped Ribbon", "Wave"]}));
assert_eq!((strs(&r["names"]), strs(&r["existing"])), (vec!["Striped Ribbon", "Wave 2"], vec!["Striped Ribbon", "Wave 2"]));
assert_eq!(undo_len(&s), before);
assert!(doc(&s).pattern("Pinstripes 3").is_none() && vectorcraft_brush::find(doc(&s), "Striped Ribbon 2").is_none());
// Undo of the first add takes the brushes, the pattern and the brush stroke back.
run(&mut s, "edit.undo", json!({}));
assert!(vectorcraft_brush::find(doc(&s), "Striped Ribbon").is_none() && doc(&s).pattern("Pinstripes 2").is_none());
assert!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().is_none_or(|st| st.brush.is_none()));
// A scatter brush brings the symbol it scatters.
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Scatter Shapes", "name": "Sparkles"}));
assert_eq!(strs(&r["added"]), ["Sparkles"]);
assert_eq!(doc(&s).symbols.iter().map(|x| x.name.as_str()).collect::<Vec<_>>(), ["Sparkle"]);
// Applying a brush the document has still applies (one step) to the paths given, without
// adding it again.
run(&mut s, "select.none", json!({}));
let before = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "scatter-shapes", "name": "Sparkles", "apply": true, "ids": [path]}));
assert_eq!((undo_len(&s), strs(&r["existing"]), r["applied"].clone()), (before + 1, vec!["Sparkles"], json!([path])));
assert_eq!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Sparkles"));
assert_eq!(doc(&s).symbols.len(), 1);
assert!(s.execute("brush.addFromLibrary", &json!({"library": "art-ribbons", "name": "Nope"})).is_err());
assert!(s.execute("brush.addFromLibrary", &json!({"name": "Wave"})).is_err());
}
/// The undo step is Add to Brushes when a brush comes in (applied or not), and Apply Brush when
/// the brushes are all there and the click only applies one; undo and redo of either restore the
/// document as it was and as the step left it.
#[test]
fn adding_library_brushes_is_add_to_brushes_and_only_applying_one_is_apply_brush() {
let mut s = session();
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap();
let label = |s: &Session| s.doc().unwrap().history.undo.last().unwrap().label.clone();
let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone());
run(&mut s, "brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Wave", "apply": true, "ids": [path]}));
assert_eq!((label(&s), stroke_brush(&s).as_deref()), ("Add to Brushes".to_string(), Some("Wave")));
run(&mut s, "brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Teardrop"}));
assert_eq!(label(&s), "Add to Brushes");
let (before, brushes_before) = (undo_len(&s), brushes(&s));
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "art-ribbons", "name": "Teardrop", "apply": true, "ids": [path]}));
assert_eq!((strs(&r["added"]), strs(&r["existing"])), (vec![], vec!["Teardrop"]));
assert_eq!((undo_len(&s), label(&s), brushes(&s)), (before + 1, "Apply Brush".to_string(), brushes_before.clone()));
assert_eq!(stroke_brush(&s).as_deref(), Some("Teardrop"));
run(&mut s, "edit.undo", json!({}));
assert_eq!((stroke_brush(&s).as_deref(), brushes(&s)), (Some("Wave"), brushes_before.clone()));
run(&mut s, "edit.redo", json!({}));
assert_eq!((stroke_brush(&s).as_deref(), label(&s)), (Some("Teardrop"), "Apply Brush".to_string()));
}
#[test]
fn pasted_brushed_art_brings_the_patterns_its_brush_art_uses() {
let mut s = kit();
let line = run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 150, "y2": 120}))["id"].as_u64().unwrap();
run(&mut s, "brush.apply", json!({"name": "Dotted Band", "ids": [line]}));
run(&mut s, "select.set", json!({"ids": [line]}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
run(&mut s, "edit.paste", json!({}));
assert!(vectorcraft_brush::find(doc(&s), "Dotted Band").is_some());
assert!(doc(&s).pattern("Dots").is_some(), "the brush's art is filled with it");
}
#[test]
fn other_documents_brushes_load_and_unreadable_files_are_errors() {
let mut s = kit();
// Another document's brushes load as a library, without its art.
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
let count = brushes(&s).len();
let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Poster.vectorcraft"}));
assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Poster"), Some(count as u64)));
let (_, lib) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap();
assert!(lib.doc.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())));
assert!(lib.brush("Dotted Band").is_some() && lib.doc.pattern("Dots").is_some());
// An SVG has the default brushes, as it does opened.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="10" height="10"/></svg>"##;
let r = run(&mut s, "brush.loadLibrary", json!({"data": svg, "name": "Box.svg"}));
assert_eq!(r["count"].as_u64(), Some(vectorcraft_brush::defaults().len() as u64), "{r}");
// Files that aren't documents, missing files and damaged native files are errors.
assert!(s.execute("brush.loadLibrary", &json!({"data": "hello", "name": "x.txt"})).is_err());
assert!(s.execute("brush.loadLibrary", &json!({"path": "/no/such/Library.vectorcraft"})).is_err());
let dir = folder("bad");
std::fs::create_dir_all(&dir).unwrap();
let bad = dir.join("Broken.vectorcraft");
std::fs::write(&bad, b"{\"format\": \"vectorcraft\", \"doc\": 7").unwrap();
assert!(s.execute("brush.loadLibrary", &json!({"path": bad.to_string_lossy()})).is_err());
// A damaged file in the user folder isn't listed.
s.brush_libraries.set_user_dir(Some(dir.to_string_lossy().to_string()));
let list = run(&mut s, "brush.libraries", json!({}));
assert!(list["libraries"].as_array().unwrap().iter().all(|l| l["category"] != "user"));
s.brush_libraries.set_user_dir(None);
let _ = std::fs::remove_dir_all(dir);
// No such brush, or a headless session's user folder, is an error.
assert!(s.execute("brush.saveLibrary", &json!({"names": ["Nope"]})).is_err());
assert!(s.execute("brush.saveLibrary", &json!({"user": true})).is_err());
// A document whose brushes were all deleted has none to save.
let all = brushes(&s);
run(&mut s, "brush.delete", json!({ "names": all }));
assert!(s.execute("brush.saveLibrary", &json!({})).is_err());
// Without a path the library comes back as the file's bytes.
let mut s = kit();
let r = run(&mut s, "brush.saveLibrary", json!({"names": ["My Nib"]}));
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(vectorcraft_brush::library(&vectorcraft_format::load(&bytes).unwrap()).len(), 1);
}
+287
View File
@@ -115,6 +115,143 @@ fn shape_builder_drag_merges_a_narrow_overlap_when_far_art_is_selected() {
assert!((area(&s, merged) - 19900.0).abs() < 1.0, "{}", area(&s, merged));
}
#[test]
fn shape_builder_drag_merges_across_an_overlap_thinner_than_the_precision() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let b = rect(&mut s, 199.999, 100.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[150, 150], [250, 150]]})).unwrap();
let out = ids(&r);
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(outline(&node(&s, out[0])).0.subpaths.len(), 1, "no hairline gap at the 0.001 pt overlap");
assert!((area(&s, out[0]) - 19999.9).abs() < 1.0, "{}", area(&s, out[0]));
// A click on one side leaves the sliver to the other square, which stays whole.
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[150, 150]]})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 9999.9).abs() < 1e-3, "{}", area(&s, merged));
let out = ids(&r);
assert_eq!(out.len(), 2, "{out:?}");
let rest = out.iter().copied().find(|i| *i != merged).unwrap();
assert!((area(&s, rest) - 10000.0).abs() < 1e-6, "{}", area(&s, rest));
}
#[test]
fn shape_builder_drag_merges_across_an_overlap_thinner_than_the_precision_with_a_line_selected() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let b = rect(&mut s, 199.999, 100.0, 100.0, 100.0);
let l = line(&mut s, (400.0, 400.0), (450.0, 450.0));
s.execute("select.set", &json!({"ids": [a.0, b.0, l.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[150, 150], [250, 150]]})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
assert_eq!(ids(&r).len(), 2, "the merged shape and the line: {r}");
assert_eq!(outline(&node(&s, merged)).0.subpaths.len(), 1, "no hairline gap at the 0.001 pt overlap");
assert!((area(&s, merged) - 19999.9).abs() < 1.0, "{}", area(&s, merged));
}
#[test]
fn shape_builder_click_beside_a_sliver_leaves_it_to_one_shape() {
let mut s = session();
// Two lines 0.005 pt apart cut a square; the strip between them is a sliver.
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let l1 = line(&mut s, (150.0, 50.0), (150.0, 250.0));
let l2 = line(&mut s, (150.005, 50.0), (150.005, 250.0));
s.execute("select.set", &json!({"ids": [a.0, l1.0, l2.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[120, 150]]})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
let rest: Vec<NodeId> = ids(&r).into_iter().filter(|i| *i != merged && outline(&node(&s, *i)).0.subpaths.iter().all(|sp| sp.closed)).collect();
assert_eq!(rest.len(), 1, "{r}");
let total = area(&s, merged) + area(&s, rest[0]);
assert!((total - 10000.0).abs() < 0.1, "the merged shape and the rest overlap: {} + {}", area(&s, merged), area(&s, rest[0]));
}
#[test]
fn shape_builder_click_beside_a_sliver_of_closed_shapes_leaves_it_to_one_shape() {
let mut s = session();
// Two squares over a third leave a strip of it 0.005 pt wide between them.
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let b = rect(&mut s, 100.0, 100.0, 49.9975, 100.0);
let c = rect(&mut s, 150.0025, 100.0, 49.9975, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[125, 150]]})).unwrap();
let out = ids(&r);
assert_eq!(out.len(), 3, "the merged shape, the rest of the back square and the right square: {r}");
let total: f64 = out.iter().map(|i| area(&s, *i)).sum();
assert!((total - 14999.75).abs() < 0.1, "the merged shape and the rest overlap: {total}");
}
#[test]
fn shape_builder_click_keeps_a_sliver_the_merge_leaves_out_in_the_rest() {
// Also with a line selected, which merges in the planar map.
for with_line in [false, true] {
let mut s = session();
// B overlaps A and C by 0.005 pt more than C does: the strip of A and B between them is a
// sliver next to the region of A alone, which a click merges without it.
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 49.995, 0.0, 50.005, 100.0);
let c = rect(&mut s, 50.0, 0.0, 50.0, 100.0);
let mut sel = vec![a.0, b.0, c.0];
if with_line {
sel.push(line(&mut s, (400.0, 400.0), (450.0, 450.0)).0);
}
s.execute("select.set", &json!({ "ids": sel })).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]]})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 4999.5).abs() < 0.1, "{}", area(&s, merged));
let out = ids(&r);
assert_eq!(out.len(), sel.len() + 1, "{r}");
// What is left of A, B and C: 5,000.5, 5,000.5 and 5,000.
let total: f64 = out.iter().map(|i| area(&s, *i)).sum();
assert!((total - 20000.5).abs() < 0.1, "what is left of the back square lost the strip: {total} ({with_line})");
}
}
#[test]
fn shape_builder_click_keeps_a_sliver_away_from_the_merge_in_the_rest() {
let mut s = session();
// Three squares over a fourth leave two strips of it 0.005 pt wide; a click merges the left
// square's region, next to the first strip only.
let a = rect(&mut s, 100.0, 100.0, 150.0, 100.0);
let b = rect(&mut s, 100.0, 100.0, 49.9975, 100.0);
let c = rect(&mut s, 150.0025, 100.0, 49.995, 100.0);
let e = rect(&mut s, 200.0025, 100.0, 49.9975, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0, e.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[125, 150]]})).unwrap();
let out = ids(&r);
assert_eq!(out.len(), 4, "{r}");
let rest = out.iter().copied().find(|i| area(&s, *i) > 9000.0).unwrap();
assert_eq!(outline(&node(&s, rest)).0.subpaths.len(), 1, "no hairline gap at the second strip");
let total: f64 = out.iter().map(|i| area(&s, *i)).sum();
assert!((total - 24999.25).abs() < 0.1, "{total}");
}
#[test]
fn shape_builder_click_keeps_a_sliver_away_from_the_merge_in_the_rest_with_a_compound_path() {
let mut s = session();
// The same with the three squares in one compound path, whose three regions have one coverage.
let a = rect(&mut s, 100.0, 100.0, 150.0, 100.0);
let b = rect(&mut s, 100.0, 100.0, 49.9975, 100.0);
let c = rect(&mut s, 150.0025, 100.0, 49.995, 100.0);
let e = rect(&mut s, 200.0025, 100.0, 49.9975, 100.0);
s.execute("select.set", &json!({"ids": [b.0, c.0, e.0]})).unwrap();
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [a.0, compound.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[125, 150]]})).unwrap();
let merged = NodeId(r["merged"].as_u64().unwrap());
assert!((area(&s, merged) - 5000.25).abs() < 0.1, "{}", area(&s, merged));
let out = ids(&r);
assert_eq!(out.len(), 3, "{r}");
// What is left of the back square holds the second strip; what is left of the compound path
// keeps the gap it had there.
let mut rest: Vec<(usize, f64)> =
out.iter().copied().filter(|i| *i != merged).map(|i| (outline(&node(&s, i)).0.subpaths.len(), area(&s, i))).collect();
rest.sort_by_key(|r| r.0);
assert_eq!(rest.iter().map(|r| r.0).collect::<Vec<_>>(), [1, 2], "{rest:?}");
assert!((rest[0].1 - 9999.75).abs() < 0.1 && (rest[1].1 - 9999.25).abs() < 0.1, "{rest:?}");
}
#[test]
fn shape_builder_click_splits_out_one_region() {
let mut s = session();
@@ -365,6 +502,32 @@ fn shape_builder_tool_drives_the_command() {
assert_eq!(layer_children(&s), 1);
}
/// A click of the Shape Builder merges over the map the tool built for the hover, as does the
/// command over the same inputs with the same gap; another gap needs a map of its own.
#[test]
fn shape_builder_merge_uses_the_map_the_tool_has() {
use vectorcraft_tools::builder::maps_built;
let mut s = session();
two(&mut s);
let v = ViewInfo::default();
s.select_tool("shapeBuilder", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap();
let built = maps_built();
s.pointer(&PointerEvent::new(PointerKind::Down, 75.0, 50.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 75.0, 50.0), v).unwrap();
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
assert_eq!(layer_children(&s), 3, "the overlap and what is left of each square");
assert_eq!(maps_built(), built, "the click builds no map");
// Undone, the squares are the inputs the tool's map was built from.
s.execute("edit.undo", &json!({})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]]})).unwrap();
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 5000.0).abs() < 1e-6);
assert_eq!(maps_built(), built, "the command over the same inputs builds no map");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]], "gap": 3})).unwrap();
assert_eq!(maps_built(), built + 1, "another gap");
}
#[test]
fn shape_builder_tool_merges_the_narrow_shape_it_highlights_in_a_wide_selection() {
let mut s = session();
@@ -1026,6 +1189,29 @@ fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses()
assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
}
#[test]
fn shape_builder_erase_drag_deletes_an_overlap_narrower_than_twice_the_tolerance() {
let mut s = session();
// A 1.5 pt overlap: every point of it is within the 2 pt tolerance of its edges.
two_at(&mut s, 98.5);
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [150, 50]], "erase": true})).unwrap();
assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(3), Some(0)), "{r}");
assert_eq!(layer_children(&s), 0, "no strip is left");
}
#[test]
fn shape_builder_erase_click_or_drag_from_inside_a_narrow_overlap_deletes_it() {
for points in [json!([[99.2, 50]]), json!([[99.2, 50], [150, 50]]), json!([[95, 10], [103.5, 60]])] {
let mut s = session();
two_at(&mut s, 98.5);
let r = s.execute("shapeBuilder.merge", &json!({"points": points, "erase": true})).unwrap();
assert_eq!(r["edges"].as_u64(), Some(0), "{points}: {r}");
let d = &s.doc().unwrap().doc;
let left: Vec<f64> = d.children(d.default_layer()).unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect();
assert!(left.iter().all(|w| *w > 2.0), "{points}: no strip is left: {left:?}");
}
}
/// In Merge Mode, Clicking Stroke Splits the Path: a click on a lone rectangle's top side, 1 pt
/// outside it, splits the rectangle into that side and the rest. Without the option, the same click
/// is refused as outside every region and leaves the rectangle as it was.
@@ -2199,6 +2385,42 @@ fn shape_builder_keeps_the_edges_of_an_unfilled_live_paint_group() {
assert!((stroked_length(&s, &out) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &out));
}
/// A plain group whose name starts with "Live Paint" is not a Live Paint group: the Shape Builder
/// takes its paths, and a merge elsewhere keeps them.
#[test]
fn shape_builder_takes_the_paths_of_a_plain_group_named_live_paint() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.edit("t", |d, _| {
d.node_mut(g).unwrap().name = Some("Live Painting".into());
Ok(())
})
.unwrap();
let c = rect(&mut s, 160.0, 20.0, 100.0, 20.0);
set_fill(&mut s, c, Color::rgb(0.0, 1.0, 0.0));
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
assert_eq!(map["regions"].as_array().unwrap().len(), 2, "{map}");
let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap();
let out = ids(&r);
assert!(out.iter().any(|i| (area(&s, *i) - 10000.0).abs() < 1.0), "the group's rectangle is kept: {r}");
// A stroke of the group's rectangle splits as a plain path's does.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[-1, 50]], "splitStroke": true})).unwrap();
assert_eq!(node(&s, g).children().unwrap().len(), 2, "{r}");
// Make Live Paint takes the group as a source rather than as a Live Paint group to merge into.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
let r = s.execute("livePaint.make", &json!({})).unwrap();
assert_eq!(r["faces"], 1, "{r}");
let made = node(&s, NodeId(r["id"].as_u64().unwrap()));
assert!(vectorcraft_tools::builder::sources(&made).unwrap().children().unwrap().iter().any(|c| c.id == g));
}
// ---------- Image Trace ----------
/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2.
@@ -2580,6 +2802,71 @@ fn image_trace_palettes_and_swatch_libraries() {
assert!(bad(json!({"palette": "rainbow"})));
}
#[test]
fn image_trace_document_library_uses_a_color_group() {
let mut s = session();
let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) {
(true, _) => [200, 40, 40, 255],
(false, true) => [40, 160, 60, 255],
(false, false) => [50, 60, 200, 255],
});
add_image(&mut s, &r, Affine::IDENTITY);
// Ungrouped swatches of the image's own colors, and a group of colors a little off them.
s.execute("swatch.new", &json!({"colors": ["#c82828", "#28a03c", "#323cc8"]})).unwrap();
s.execute("swatch.newGroup", &json!({"name": "Brand", "colors": ["#b43c3c", "#3c8c50", "#4650b4"]})).unwrap();
s.execute("swatch.newGroup", &json!({"name": "Empty"})).unwrap();
s.execute("swatch.newGroup", &json!({"name": "Été", "colors": ["#b43c3c", "#3c8c50", "#4650b4"]})).unwrap();
let hex = |c: &Color| {
let [r, g, b, _] = c.to_rgba8(1.0);
format!("#{r:02x}{g:02x}{b:02x}")
};
let trace = |s: &mut Session, extra: Value| {
let mut params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("imageTrace.makeAndExpand", &json!({"params": params}))
};
let fills = |s: &Session, id: &Value| -> Vec<String> {
let g = node(s, NodeId(id.as_u64().unwrap()));
let mut v: Vec<String> = g.children().unwrap().iter().filter_map(|n| n.appearance.fill_paint().color()).map(|c| hex(&c)).collect();
v.sort();
v.dedup();
v
};
// Every swatch: the image's own colors win.
let r = trace(&mut s, json!({})).unwrap();
assert_eq!(fills(&s, &r["id"]), ["#28a03c", "#323cc8", "#c82828"]);
s.execute("edit.undo", &json!({})).unwrap();
// The group: its colors only, and its name matches whatever its case.
for name in ["Brand", "brand", "Été", "été", "ÉTÉ"] {
let r = trace(&mut s, json!({"colorGroup": name})).unwrap();
assert_eq!(fills(&s, &r["id"]), ["#3c8c50", "#4650b4", "#b43c3c"], "{name}");
s.execute("edit.undo", &json!({})).unwrap();
}
// An empty name is every swatch.
let r = trace(&mut s, json!({"colorGroup": ""})).unwrap();
assert_eq!(fills(&s, &r["id"]), ["#28a03c", "#323cc8", "#c82828"]);
s.execute("edit.undo", &json!({})).unwrap();
let err = |s: &mut Session, extra: Value| trace(s, extra).unwrap_err().to_string();
assert!(err(&mut s, json!({"colorGroup": "Nope"})).contains("no color group `Nope`"));
assert!(err(&mut s, json!({"colorGroup": "Empty"})).contains("`Empty` has no colours"));
assert!(err(&mut s, json!({"colorGroup": "Brand", "library": "web-safe-216"})).contains("needs `library: \"document\"`"));
// Every other palette refuses a group too, in any mode; an empty name is no group.
for palette in ["limited", "fullTone", "automatic"] {
let e = err(&mut s, json!({"colorGroup": "Brand", "palette": palette}));
assert!(e.contains("`colorGroup` needs `palette: \"documentLibrary\"`"), "{palette}: {e}");
}
let e = err(&mut s, json!({"colorGroup": "Brand", "mode": "grayscale", "palette": "limited"}));
assert!(e.contains("needs `palette: \"documentLibrary\"`"), "{e}");
// Document Library uses the group in Color mode only.
for mode in ["blackAndWhite", "grayscale", "logo"] {
let e = err(&mut s, json!({"colorGroup": "Brand", "mode": mode}));
assert!(e.contains("`colorGroup` needs `mode: \"color\"`"), "{mode}: {e}");
}
s.execute("imageTrace.makeAndExpand", &json!({"params": {"mode": "color", "palette": "limited", "colors": 3, "colorGroup": ""}})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
trace(&mut s, json!({"mode": "grayscale", "colorGroup": ""})).unwrap();
}
#[test]
fn image_trace_create_strokes_makes_stroked_centre_lines() {
let mut s = session();
+59 -2
View File
@@ -1,10 +1,11 @@
//! General › Constrain Angle turns the axes the arrow keys, Shift-constrained drags and the
//! Rectangle, Rounded Rectangle and Ellipse tools work along (G12, #394, first batch).
//! Rectangle, Rounded Rectangle and Ellipse tools work along, and General › Corner Radius is the
//! Rounded Rectangle tool's radius (G12, #394, first batch).
use serde_json::{Value, json};
use vectorcraft_doc::{LiveShape, NodeKind};
use vectorcraft_geom::{Point, Vec2};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
@@ -177,3 +178,59 @@ fn constrain_angle_keeps_guides_selected_with_art_on_the_page_axes() {
s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "copy": true})).unwrap();
assert_eq!(pos(&s), [60.0, 60.0, 70.0, 60.0]);
}
/// General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is
/// active reaches it, and the radius ↑ sets while drawing one becomes the preference.
#[test]
fn corner_radius_is_the_rounded_rectangle_tools_radius() {
use PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let v = ViewInfo::default();
set_pref(&mut s, "cornerRadius", json!(7));
s.select_tool("roundedRectangle", v).unwrap();
let draw = |s: &mut Session, y: f64, up: bool| {
gesture(s, Mods::default(), &[(Down, 100.0, y), (Drag, 150.0, y + 30.0), (Drag, 200.0, y + 60.0)]);
if up {
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
}
gesture(s, Mods::default(), &[(Up, 200.0, y + 60.0)]);
live_box(s, last_id(s)).3
};
assert!(close(draw(&mut s, 10.0, false), 7.0), "the preference's radius");
// Set while the tool is active: the tool takes it.
set_pref(&mut s, "cornerRadius", json!(4));
assert!(close(draw(&mut s, 100.0, false), 4.0), "the new preference's radius");
assert!(close(draw(&mut s, 200.0, true), 5.0), "↑ adds a point");
assert_eq!(s.execute("prefs.get", &json!({"key": "cornerRadius"})).unwrap(), json!(5.0), "and it is the preference now");
s.select_tool("selection", v).unwrap();
s.select_tool("roundedRectangle", v).unwrap();
assert_eq!(s.tool_options()["cornerRadius"], json!(5.0), "chosen again, the tool keeps it");
// A tool option set from the Tool Options dialog is the preference too, within its range.
s.set_tool_option("cornerRadius", &json!(5000));
assert_eq!(s.execute("prefs.get", &json!({"key": "cornerRadius"})).unwrap(), json!(1296.0));
assert_eq!(s.tool_options()["cornerRadius"], json!(1296.0), "and the tool itself stops at the range");
draw(&mut s, 300.0, true);
assert_eq!(s.tool_options()["cornerRadius"], json!(1296.0), "↑ at the top stays there");
}
/// General › Corner Radius set for the Rounded Rectangle tool by name (`tool.setOption {tool}`)
/// while it has lent a selection tool for a Cmd-drag reaches the tool kept aside: back from the
/// drag, it draws the radius that is the preference now, not the one from before.
#[test]
fn corner_radius_set_by_name_reaches_the_tool_that_lent_a_selection_tool() {
use PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let v = ViewInfo::default();
set_pref(&mut s, "cornerRadius", json!(7));
s.select_tool("roundedRectangle", v).unwrap();
let cmd = Mods { cmd: true, ..Mods::default() };
gesture(&mut s, cmd, &[(Down, 300.0, 300.0), (Drag, 320.0, 320.0)]);
assert_eq!(s.tool_id(), "selection", "Cmd lends the Selection tool");
s.set_tool_option_cmd(&json!({"tool": "roundedRectangle", "key": "cornerRadius", "value": 9})).unwrap();
assert_eq!(s.execute("prefs.get", &json!({"key": "cornerRadius"})).unwrap(), json!(9.0), "the preference");
gesture(&mut s, cmd, &[(Up, 320.0, 320.0)]);
assert_eq!(s.tool_id(), "roundedRectangle", "the drag over, the tool is back");
assert_eq!(s.tool_options()["cornerRadius"], json!(9.0), "with the radius set while it was aside");
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 150.0, 130.0), (Drag, 200.0, 160.0), (Up, 200.0, 160.0)]);
assert!(close(live_box(&s, last_id(&s)).3, 9.0), "and draws it");
}
+11
View File
@@ -161,6 +161,17 @@ fn rect_cut_crops_to_real_geometry() {
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err());
}
#[test]
fn rect_cut_keeps_a_long_line_through_a_narrow_rectangle() {
// A 200 pt line used to be tested every 200 / 24 pt (at 400 and 408.33 near here), so a
// rectangle between two of those points kept nothing of it.
let mut s = session();
let line = path(&mut s, &[(300.0, 150.0), (500.0, 150.0)], false);
select(&mut s, &[line]);
s.execute("path.rectCut", &json!({"rect": [402, 100, 4, 100]})).unwrap();
assert!(near(bounds(&s, line), Rect::new(402.0, 150.0, 406.0, 150.0)));
}
#[test]
fn mirror_cut_makes_a_symmetric_shape() {
let mut s = session();
+42
View File
@@ -288,6 +288,48 @@ fn setup_survives_save_and_load() {
assert_eq!(back.setup.grid_colors_name(), "Custom");
}
/// Highlight Substituted Fonts is a preference (Preferences › Type): `document.setup` does not
/// report it, and ignores it with a warning that names `prefs.set`, so an older build's reply
/// passed back still applies its other keys. The document's stored value and the preference stay.
#[test]
fn highlight_substituted_fonts_is_ignored_with_a_pointer_to_the_preference() {
let mut s = session();
let q = s.execute("document.setup", &json!({})).unwrap();
assert!(q.get("highlightSubstitutedFonts").is_none() && q.get("warnings").is_none(), "{q}");
let r = s.execute("document.setup", &json!({"highlightSubstitutedFonts": false, "bleed": 9})).unwrap();
let w = r["warnings"][0].as_str().unwrap_or_default();
assert!(w.contains("prefs.set") && w.contains("highlightSubstitutedFonts"), "{r}");
assert_eq!(setup(&s).bleed, [9.0; 4]);
assert!(setup(&s).highlight_substituted_fonts, "the stored value is not changed");
assert!(s.prefs.highlight_substituted_fonts, "nor is the preference");
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
// The reply, warning included, can be passed back; alone, the key changes nothing.
let mut plain = r.clone();
plain.as_object_mut().unwrap().remove("warnings");
assert_eq!(s.execute("document.setup", &r).unwrap(), plain);
assert_eq!(s.execute("document.setup", &json!({"highlightSubstitutedFonts": true})).unwrap()["warnings"], r["warnings"]);
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
let e = s.execute("document.setup", &json!({"highlightSubstitutedFonts": 1})).unwrap_err().to_string();
assert!(e.contains("true or false"), "{e}");
}
/// A file saved with Document Setup's old Highlight Substituted Fonts value opens, keeps that
/// value when saved again, and leaves the preference as it was.
#[test]
fn a_file_with_the_old_highlight_substituted_fonts_value_opens_and_keeps_it() {
let mut s = session();
let mut d = (*s.doc().unwrap().doc).clone();
d.setup.highlight_substituted_fonts = false;
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "old.vectorcraft", "dataBase64": data})).unwrap();
assert!(!setup(&s).highlight_substituted_fonts);
assert!(s.prefs.highlight_substituted_fonts, "the preference decides the screen");
let again = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
assert!(!again.setup.highlight_substituted_fonts);
let q = s.execute("document.setup", &json!({})).unwrap();
assert_eq!(s.execute("document.setup", &q).unwrap(), q);
}
/// A file whose bleed, superscript, subscript or small caps size lies outside the range
/// `document.setup` takes opens with it at the nearer end of that range, and the setup it reports
/// can be fed back.
+368 -2
View File
@@ -178,6 +178,90 @@ fn pencil_continues_selected_open_path() {
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
}
/// Connect two paths: with two open paths selected, a Pencil stroke from an end of one to an end
/// of the other joins them into one path, the first one's, in one undo step. A path that is not
/// selected is not joined.
#[test]
fn pencil_connects_two_selected_paths_from_an_end_of_one_to_an_end_of_the_other() {
let stroke = [(201.0, 101.0), (230.0, 130.0), (270.0, 180.0), (299.0, 199.0)];
// The stroke ends on the second path's start, then on its end.
for (b_from, b_to) in [((300.0, 200.0), (400.0, 200.0)), ((400.0, 200.0), (300.0, 200.0))] {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let b = line(&mut s, b_from.0, b_from.1, b_to.0, b_to.1);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.select_tool("pencil", view()).unwrap();
assert_eq!(gesture(&mut s, &stroke, Mods::default()), 1, "{b_from:?}: one undo step");
let ps = paths(&s);
assert_eq!(ps.len(), 1, "{b_from:?}: the second path is joined on");
assert_eq!(ps[0].0, a);
let sp = &ps[0].1.subpaths[0];
assert!(!sp.closed);
assert_eq!(sp.anchors[0].p, Point::new(100.0, 100.0));
assert_eq!(sp.anchors[sp.anchors.len() - 1].p, Point::new(400.0, 200.0), "{b_from:?}");
assert_eq!(sp.anchors.iter().filter(|x| x.p == Point::new(300.0, 200.0)).count(), 1, "{b_from:?}: one anchor where they meet");
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
}
// The second path is not selected: the first is continued and the second left as it was.
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let b = line(&mut s, 300.0, 200.0, 400.0, 200.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
drag(&mut s, "pencil", &stroke, Mods::default());
assert_eq!(paths(&s).len(), 2);
assert_eq!(path(&s, b).anchor_count(), 2);
assert!(path(&s, a).anchor_count() >= 3, "continued");
assert!(path(&s, a).subpaths[0].anchors.iter().all(|x| x.p != Point::new(400.0, 200.0)));
}
/// `path.freehand`'s connect takes the other path's last open subpath and leaves the rest of it,
/// whose direct-selected anchors follow their subpaths; it refuses the path it extends and an end
/// other than "start" or "end".
#[test]
fn pencil_connect_keeps_the_rest_of_a_compound_path_and_refuses_the_extended_path() {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let d = "M300 200 L400 200 M300 400 L400 400 M0 500 L50 500 L50 550 Z";
let bc = NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap());
let points = json!([[200, 100], [250, 250], [400, 400]]);
for end in [json!("middle"), json!(1), json!(null)] {
let bad = json!({"points": points, "extend": {"id": a.0, "end": "end"}, "connect": {"id": bc.0, "end": end}});
assert!(s.execute("path.freehand", &bad).is_err(), "{bad}");
}
assert_eq!((path(&s, a).anchor_count(), path(&s, bc).subpaths.len()), (2, 3), "refused: nothing changed");
s.execute("select.anchors", &json!({"id": bc.0, "anchors": [[0, 1], [1, 0], [2, 2]], "mode": "set"})).unwrap();
let connect = json!({"points": points, "extend": {"id": a.0, "end": "end"}, "connect": {"id": bc.0, "end": "end"}, "keepSelected": false});
s.execute("path.freehand", &connect).unwrap();
let left = path(&s, bc);
assert_eq!(left.subpaths.len(), 2, "the other subpaths stay");
assert_eq!(left.subpaths[0].anchors[0].p, Point::new(300.0, 200.0));
let anchors: Vec<_> = s.doc().unwrap().selection.anchors.get(&bc).into_iter().flatten().copied().collect();
assert_eq!(anchors, vec![(0, 1), (1, 2)], "the joined subpath's anchor goes, the closed one's moves down");
let sp = &path(&s, a).subpaths[0];
assert_eq!(sp.anchors[sp.anchors.len() - 1].p, Point::new(300.0, 400.0));
assert!(sp.anchors.iter().any(|x| x.p == Point::new(400.0, 400.0)));
assert!(s.execute("path.freehand", &json!({"points": points, "extend": {"id": a.0}, "connect": {"id": a.0}})).is_err());
}
/// With Keep selected off, a compound path whose direct-selected anchors all lie on the subpath
/// that connect joins on is no longer selected, and Delete then has nothing to delete.
#[test]
fn pencil_connect_deselects_a_path_whose_selected_anchors_were_all_joined_on() {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let d = "M300 200 L400 200 M300 400 L400 400 M0 500 L50 500 L50 550 Z";
let bc = NodeId(s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": bc.0, "anchors": [[1, 0], [1, 1]], "mode": "set"})).unwrap();
let points = json!([[200, 100], [250, 250], [400, 400]]);
let connect = json!({"points": points, "extend": {"id": a.0, "end": "end"}, "connect": {"id": bc.0, "end": "end"}, "keepSelected": false});
s.execute("path.freehand", &connect).unwrap();
let sel = s.doc().unwrap().selection.clone();
assert!(!sel.contains(bc) && !sel.anchors.contains_key(&bc) && !sel.segments.contains_key(&bc), "{sel:?}");
assert!(s.execute("edit.clear", &json!({})).is_err(), "nothing selected");
let left = path(&s, bc);
assert_eq!((left.subpaths.len(), left.anchor_count()), (2, 5), "{left:?}");
}
/// Edit Selected Paths in the middle of a path (#2850): a Pencil stroke that starts and ends on a
/// selected path replaces the section between, drawn either way; the path stays one path with
/// its ends, and is one undo step.
@@ -267,6 +351,42 @@ fn pencil_with_keep_selected_off_leaves_the_new_path_unselected() {
assert!(selected(&s).is_empty());
}
/// `fit: false` (the Pencil's preview with Live curve fitting off) joins the stroke's points with
/// straight segments: a new path, a continued one and a redrawn section; by default the stroke is
/// fitted.
#[test]
fn freehand_with_fit_off_joins_the_points_with_straight_segments() {
let corners = |sp: &vectorcraft_geom::SubPath| sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p);
let at = |sp: &vectorcraft_geom::SubPath| sp.anchors.iter().map(|a| (a.p.x, a.p.y)).collect::<Vec<_>>();
let pts = wave(100.0, 400.0, 100.0);
let mut s = session();
let fitted = s.execute("path.freehand", &json!({"points": pts.iter().map(|p| [p.0, p.1]).collect::<Vec<_>>()})).unwrap();
let fitted = path(&s, NodeId(fitted["id"].as_u64().unwrap()));
assert!(!corners(&fitted.subpaths[0]) && fitted.subpaths[0].anchors.len() < pts.len(), "fitted by default");
let unfitted = s.execute("path.freehand", &json!({"points": pts.iter().map(|p| [p.0, p.1]).collect::<Vec<_>>(), "fit": false})).unwrap();
let sp = path(&s, NodeId(unfitted["id"].as_u64().unwrap())).subpaths[0].clone();
assert!(corners(&sp) && at(&sp) == pts, "{:?}", at(&sp));
// Continued from the end of a selected open path: the line's anchors, then the stroke's points.
let l = line(&mut s, 0.0, 300.0, 100.0, 300.0);
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("path.freehand", &json!({"points": [[100, 300], [150, 330], [200, 290], [250, 320]], "extend": {"id": l.0}, "fit": false})).unwrap();
let sp = path(&s, l).subpaths[0].clone();
assert!(corners(&sp) && at(&sp) == [(0.0, 300.0), (100.0, 300.0), (150.0, 330.0), (200.0, 290.0), (250.0, 320.0)], "{:?}", at(&sp));
// A redrawn section: the stroke's inner points between where it meets the line.
let r = line(&mut s, 0.0, 500.0, 300.0, 500.0);
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
s.execute(
"path.freehand",
&json!({"points": [[100, 501], [125, 470], [150, 460], [175, 470], [200, 501]], "redraw": {"id": r.0, "within": 4}, "fit": false}),
)
.unwrap();
let sp = path(&s, r).subpaths[0].clone();
assert!(corners(&sp), "{:?}", sp.anchors);
let inner: Vec<_> = at(&sp).into_iter().filter(|p| p.1 < 499.0).collect();
assert_eq!(inner, [(125.0, 470.0), (150.0, 460.0), (175.0, 470.0)]);
assert!(s.execute("path.freehand", &json!({"points": [[0, 0], [1, 1]], "fit": "no"})).is_err());
}
#[test]
fn paintbrush_uses_stroke_blob_brush_fills() {
let mut s = session();
@@ -689,6 +809,81 @@ fn eraser_removes_small_shape_entirely() {
assert!(paths(&s).is_empty());
}
/// The Eraser's tip is an ellipse `size` across its long axis, at `angle` counter-clockwise from
/// horizontal, with a short axis `roundness` percent as long: a vertical drag across a square
/// removes a band as wide as the tip is across, and a press on a horizontal line removes the
/// tip's horizontal chord through its center.
#[test]
fn the_eraser_tip_takes_its_angle_and_roundness() {
// The area of a 100 pt square left after a vertical drag through its middle, and the length
// of a 200 pt line left after a press on its middle, with a 20 pt tip.
let erase = |tip: serde_json::Value| {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
line(&mut s, 300.0, 150.0, 500.0, 150.0);
let mut stroke = json!({"points": [[150, 50], [150, 250]], "size": 20});
let mut press = json!({"points": [[400, 150]], "size": 20});
for p in [&mut stroke, &mut press] {
if let (Some(p), Some(tip)) = (p.as_object_mut(), tip.as_object()) {
p.extend(tip.clone());
}
}
// With nothing selected each time, so the Eraser erases every path it touches.
s.execute("select.none", &json!({})).unwrap();
s.execute("path.eraseRegion", &stroke).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("path.eraseRegion", &press).unwrap();
let ps = paths(&s);
let left: f64 = ps.iter().map(|p| &p.1).filter(|p| p.is_closed()).map(area).sum();
let len: f64 = ps.iter().map(|p| &p.1).filter(|p| !p.is_closed()).map(|p| p.length()).sum();
(left, len)
};
// `band`: how wide the drag cuts; `chord`: how much of the line the press removes.
let close = |(left, len): (f64, f64), band: f64, chord: f64| {
assert!((left - (10000.0 - 100.0 * band)).abs() < 2.0 * band + 2.0, "{band} pt band: {left} left of the square");
assert!((len - (200.0 - chord)).abs() < 0.03 * chord + 0.02, "{chord} pt chord: {len} left of the line");
};
// Round by default: 20 pt either way.
close(erase(json!({})), 20.0, 20.0);
// The long axis horizontal: 20 pt either way.
close(erase(json!({"angle": 0, "roundness": 25})), 20.0, 20.0);
// The long axis upright: 5 pt either way.
close(erase(json!({"angle": 90, "roundness": 25})), 5.0, 5.0);
// At 45°, half axes 10 and 2.5: the band is 2·√((10² + 2.5²) / 2), about 14.58 pt, and the
// chord 2 / √(0.5 / 10² + 0.5 / 2.5²), about 6.86 pt.
close(erase(json!({"angle": 45, "roundness": 25})), 2.0 * ((100.0f64 + 6.25) / 2.0).sqrt(), 2.0 / (0.5f64 / 100.0 + 0.5 / 6.25).sqrt());
// A roundness past 100 is kept to 100, and one below 1 to 1.
close(erase(json!({"angle": 90, "roundness": 400})), 20.0, 20.0);
close(erase(json!({"angle": 90, "roundness": 0})), 0.2, 0.2);
}
#[test]
fn a_narrow_eraser_finds_a_long_line_between_its_samples() {
// A 200 pt line from x = 300 used to be tested every 200 / 24 pt (at 400 and 408.33 near
// here), so a tip or a Path Eraser band that fits between two of those points missed it.
let left_after = |command: &str, p: serde_json::Value| {
let mut s = session();
line(&mut s, 300.0, 150.0, 500.0, 150.0);
s.execute(command, &p).unwrap();
paths(&s).iter().map(|p| p.1.length()).sum::<f64>()
};
// A 20 pt tip upright at 25%: 5 pt across the line, from 401.5 to 406.5.
let len = left_after("path.eraseRegion", json!({"points": [[404, 150]], "size": 20, "angle": 90, "roundness": 25}));
assert!((len - 195.0).abs() < 0.2, "the narrow tip left {len} pt of the line");
// An 8 pt Path Eraser band across the line, from 400.2 to 408.2.
let len = left_after("path.eraseSegments", json!({"points": [[404.2, 100], [404.2, 200]], "width": 8}));
assert!((len - 192.0).abs() < 0.2, "the Path Eraser left {len} pt of the line");
// The same line drawn as a curve whose handles both sit on its start moves slowly there and
// fast near its end, three times as fast as its length suggests, so 80 even steps in t put
// samples at 492.59 and 500 and a 5 pt tip at 496.3 sits between them.
let mut s = session();
let anchors = json!([{"x": 300, "y": 150}, {"x": 500, "y": 150, "in": [300, 150]}]);
s.execute("path.create", &json!({ "anchors": anchors })).unwrap();
s.execute("path.eraseRegion", &json!({"points": [[496.3, 150]], "size": 5})).unwrap();
let len = paths(&s).iter().map(|p| p.1.length()).sum::<f64>();
assert!((len - 195.0).abs() < 0.2, "the tip left {len} pt of the curve");
}
#[test]
fn path_eraser_splits_selected_path() {
let mut s = session();
@@ -749,6 +944,21 @@ fn join_tool_joins_two_lines() {
assert_eq!(p.subpaths[0].anchors[2].p, Point::new(300.0, 150.0));
}
/// #2899: a scrub from one open end toward the other joins them when the line passes near the
/// endpoints, not only when it crosses them.
#[test]
fn join_tool_joins_endpoints_near_the_scrub_ends() {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
line(&mut s, 205.0, 100.0, 300.0, 150.0);
s.execute("select.none", &json!({})).unwrap();
drag(&mut s, "join", &[(198.0, 92.0), (207.0, 92.0)], Mods::default());
assert_eq!(paths(&s).len(), 1);
let p = path(&s, a);
assert_eq!(p.anchor_count(), 3);
assert_eq!(p.subpaths[0].anchors[1].p, Point::new(202.5, 100.0));
}
#[test]
fn line_family_tools_draw_and_click_asks_dialog() {
let mut s = session();
@@ -962,8 +1172,8 @@ fn drawing_commands_refuse_a_point_or_pressure_given_as_text() {
s.execute("path.freehand", &pressed).unwrap();
}
/// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, width, radius,
/// fidelity or tolerance given as text or as another value that is not a number, and change
/// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, angle, roundness,
/// width, radius, fidelity or tolerance given as text or as another value that is not a number, and change
/// nothing; null is read as the key left out.
#[test]
fn drawing_commands_refuse_a_size_given_as_text() {
@@ -973,6 +1183,8 @@ fn drawing_commands_refuse_a_size_given_as_text() {
let stroke = json!({"points": [[90, 150], [150, 150], [210, 150]]});
for (cmd, key) in [
("path.eraseRegion", "size"),
("path.eraseRegion", "angle"),
("path.eraseRegion", "roundness"),
("path.eraseSegments", "width"),
("path.smoothRegion", "radius"),
("path.smoothRegion", "fidelity"),
@@ -2188,6 +2400,20 @@ fn join_takes_the_paths_and_the_ends_to_join() {
}
}
/// Without `ends`, the end points picked with the direct selection are the ones joined.
#[test]
fn join_uses_the_selected_end_points() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
let b = line(&mut s, 110.0, 0.0, 200.0, 0.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "add"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect();
assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]);
}
/// `path.join {to}` makes the joined anchor a corner or smooth point.
#[test]
fn join_to_corner_or_smooth_sets_the_merged_anchor() {
@@ -2446,3 +2672,143 @@ fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() {
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
}
}
/// The anchors of a closed, slightly wobbly blob drawn at `fidelity`: samples half a point apart,
/// with a small tremor on top of the outline.
fn blob_anchors(s: &mut Session, fidelity: f64) -> usize {
let pts: Vec<_> = (0..1200)
.map(|i| {
let a = i as f64 / 1200.0 * std::f64::consts::TAU;
let r = 90.0 + 12.0 * (3.0 * a).sin() + 0.15 * (97.0 * a).sin();
json!([400.0 + r * a.cos(), 300.0 + r * a.sin()])
})
.collect();
let r = s.execute("path.freehand", &json!({"points": pts, "closed": true, "fidelity": fidelity, "keepSelected": false})).unwrap();
path(s, NodeId(r["id"].as_u64().unwrap())).subpaths.iter().map(|sp| sp.anchors.len()).sum()
}
/// Fidelity goes down to 0.01 pt (#3328), and a lower one never keeps fewer anchors.
#[test]
fn pencil_fidelity_goes_down_to_a_hundredth_and_lower_keeps_more() {
let mut s = session();
let counts: Vec<_> = [0.5, 0.1, 0.05, 0.02, 0.01].into_iter().map(|f| blob_anchors(&mut s, f)).collect();
assert!(counts.windows(2).all(|w| w[0] <= w[1]), "{counts:?}");
// Below the old floor of 0.05 the stroke keeps more of itself.
assert!(counts[4] > counts[2], "{counts:?}");
// Below 0.01 is read as 0.01.
assert_eq!(blob_anchors(&mut s, 0.001), counts[4]);
assert_eq!(blob_anchors(&mut s, 0.0), counts[4]);
}
/// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never
/// crash, and what they draw has finite anchors.
#[test]
fn pencil_at_the_lowest_fidelity_takes_degenerate_strokes() {
let mut s = session();
for points in [
json!([[10, 10]]),
json!([[10, 10], [10, 10]]),
json!([[10, 10], [10, 10], [10, 10], [10, 10]]),
json!([[10, 10], [10.001, 10]]),
json!([[10, 10], [10.004, 10.003], [10.002, 10.001], [10.006, 10]]),
json!([[10, 10], [20, 10], [10, 10]]),
json!([[10, 10], [20, 10], [20, 10], [20, 10], [30, 10]]),
] {
for closed in [false, true] {
let p = json!({"points": points, "closed": closed, "fidelity": 0.01, "keepSelected": false});
let Ok(r) = s.execute("path.freehand", &p) else { continue };
let Some(id) = r["id"].as_u64() else { continue };
let Some(pd) = s.doc().unwrap().doc.node(NodeId(id)).and_then(|n| n.path_data().cloned()) else { continue };
for a in pd.subpaths.iter().flat_map(|sp| &sp.anchors) {
assert!([a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite()), "{points} closed {closed}");
}
}
}
}
/// The Pencil's Fidelity option takes 0.01, and the Smooth tool's stays at 0.05 or more.
#[test]
fn pencil_option_takes_a_hundredth_and_smooth_keeps_its_floor() {
let mut s = session();
for (tool, want) in [("pencil", 0.01), ("paintbrush", 0.01), ("smooth", 0.05)] {
s.set_tool_option_cmd(&json!({"tool": tool, "values": {"fidelity": 0.001}})).unwrap();
assert_eq!(s.tool_options_of(tool)["fidelity"].as_f64(), Some(want), "{tool}");
s.set_tool_option_cmd(&json!({"tool": tool, "values": {"fidelity": 0.02}})).unwrap();
assert_eq!(s.tool_options_of(tool)["fidelity"].as_f64(), Some(if tool == "smooth" { 0.05 } else { 0.02 }), "{tool}");
}
}
/// A selected compound path (a shape with a hole) is overdrawn as its paths are: a Pencil stroke
/// starting and ending on its outer path, or on its hole, redraws that member path, draws no new
/// path, and leaves the compound path selected, so the next stroke redraws it too.
#[test]
fn pencil_redraws_a_selected_compound_path() {
let mut s = session();
s.execute("select.none", &json!({})).unwrap();
let ring = |cx: f64, rx: f64, ry: f64| -> Vec<serde_json::Value> {
(0..72)
.map(|i| {
let a = f64::from(i * 5).to_radians();
json!([cx + rx * a.cos(), 300.0 + ry * a.sin()])
})
.collect()
};
let outer = s.execute("path.freehand", &json!({"points": ring(400.0, 150.0, 90.0), "closed": true})).unwrap()["id"].as_u64().unwrap();
let hole = s.execute("path.freehand", &json!({"points": ring(380.0, 60.0, 30.0), "closed": true})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [outer, hole]})).unwrap();
s.execute("object.compoundPath.make", &json!({})).unwrap();
let compound = s.doc().unwrap().selection.objects.clone();
assert_eq!(compound.len(), 1);
let members = |s: &Session| -> Vec<PathData> {
s.doc().unwrap().doc.node(compound[0]).unwrap().children().unwrap().iter().map(|c| c.path_data().unwrap().clone()).collect()
};
let before = members(&s);
let v = view();
s.select_tool("pencil", v).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
let stroke = |s: &mut Session, pts: &[(f64, f64)]| {
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1), v).unwrap();
};
// Over the outer path's right side, bowing outward: from (550, 300) round to (520, 360).
stroke(&mut s, &[(550.0, 300.0), (575.0, 315.0), (580.0, 335.0), (560.0, 355.0), (520.0, 360.0)]);
assert_eq!(count(&s), 1, "no new path");
assert_eq!(s.doc().unwrap().selection.objects, compound, "the compound path stays selected");
let after = members(&s);
assert!(after[0] != before[0] && after[1] == before[1], "the outer path is redrawn, the hole left alone");
// Then over the hole's left side: the compound path is still the one redrawn.
stroke(&mut s, &[(320.0, 300.0), (310.0, 290.0), (312.0, 280.0), (330.0, 274.0)]);
assert_eq!(count(&s), 1, "no new path");
assert_eq!(s.doc().unwrap().selection.objects, compound);
assert!(members(&s)[1] != before[1], "the hole is redrawn");
}
/// With the Pencil's Live preview off, nothing is added to the document while a filled stroke is
/// drawn; the release draws the path with its fill, as one undo step.
#[test]
fn pencil_with_live_preview_off_draws_the_filled_path_on_release() {
let mut s = session();
s.execute("select.none", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.set_tool_option_cmd(&json!({"tool": "pencil", "values": {"livePreview": false, "fill": true}})).unwrap();
assert_eq!(s.tool_options_of("pencil")["livePreview"], json!(false));
let v = view();
s.select_tool("pencil", v).unwrap();
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
let (before, undo) = (count(&s), s.doc().unwrap().history.undo.len());
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
for (x, y) in [(160.0, 110.0), (190.0, 170.0), (120.0, 190.0), (105.0, 120.0)] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
assert_eq!(count(&s), before, "nothing drawn while the stroke is drawn");
}
s.pointer(&PointerEvent::new(PointerKind::Up, 105.0, 120.0), v).unwrap();
assert_eq!(count(&s), before + 1, "the release draws the path");
let doc = &s.doc().unwrap().doc;
let n = doc.layers[0].children().unwrap().last().unwrap();
assert!(n.path_data().is_some() && !n.appearance.fill_paint().is_none(), "{:?}", n.appearance);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
}
+135 -1
View File
@@ -3,7 +3,7 @@
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, SurroundingUnit, ToolKey};
use super::*;
@@ -184,3 +184,137 @@ fn discard_empty_command() {
assert_eq!(undo_labels(&s), ["Type", "Rectangle"]);
assert_eq!(texts(&s).len(), 1);
}
/// An input method that replaces text around its composition (macOS press-and-hold/dictation,
/// Wayland text-input-v3): `DeleteSurroundingBytes` counts exact UTF-8 bytes and shares the
/// commit's typing session, so the replacement is one undo step. Never splits a UTF-8 sequence.
#[test]
fn ime_delete_surrounding_bytes_replaces_text_in_one_undo_step() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Héllo", v).unwrap();
let steps = undo_labels(&s).len();
// "llo" is three UTF-8 bytes before the caret; the commit inserts y in the same session.
s.tool_ime_delete_surrounding(3, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("y", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Héy"]);
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
}
/// Character counts (upstream `DeleteSurrounding`) delete whole scalar values, and a byte count
/// that lands inside a multi-byte character deletes fewer bytes rather than splitting it.
#[test]
fn ime_delete_surrounding_counts_characters_and_respects_utf8_boundaries() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("aé", v).unwrap();
// A byte count starting inside é (byte 2 of the text) moves forward to the boundary: nothing
// is deleted rather than splitting the character, and the commit still goes in.
s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("!", v).unwrap();
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["aé!"]);
// One character before the caret removes the whole "!"; é itself is one character too.
s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Chars, v).unwrap();
s.tool_text("z", v).unwrap();
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["aéz"]);
}
/// A selection is what an IME's surrounding bytes replace, and bytes after it count too; the
/// commit shares the one typing step.
#[test]
fn ime_delete_surrounding_replaces_a_selection_and_after_bytes() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Hello", v).unwrap();
// Caret before "lo"; Shift+Left selects the first "l".
key(&mut s, ToolKey::Left);
key(&mut s, ToolKey::Left);
s.tool_key(ToolKey::Left, Mods { shift: true, ..Mods::default() }, v).unwrap();
let steps = undo_labels(&s).len();
// One byte before the selection ("e") and one after it ("o") go with it.
s.tool_ime_delete_surrounding(1, 1, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("y", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hyo"]);
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
}
/// A pre-edit in the way is replaced by the IME's surrounding deletion, then the commit lands in
/// the same typing step.
#[test]
fn ime_delete_surrounding_replaces_the_preedit_and_commit_in_one_step() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("ab", v).unwrap();
s.tool_preedit("xy", None, v).unwrap();
let steps = undo_labels(&s).len();
// Two bytes before the pre-edit: "ab" and the marked "xy" all go, then é is committed.
s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("é", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["é"]);
assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step");
}
/// RTL text follows logical UTF-8 byte offsets too: the byte count before the pre-edit deletes
/// the whole preceding Hebrew character (two bytes), never splitting it.
#[test]
fn ime_delete_surrounding_uses_logical_bytes_for_rtl_text() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("שלום", v).unwrap();
s.tool_preedit("xy", None, v).unwrap();
s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap();
s.tool_text("א", v).unwrap();
key(&mut s, ToolKey::Escape);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["שלוא"]);
}
/// The Type tool's IME owner is stable across ordinary edits in one document and changes with the
/// document (a new tab/file has a new process-unique uid).
#[test]
fn ime_target_survives_edits_and_changes_with_the_document() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
let first = s.tool_ime_target(v).expect("editing has a target");
s.tool_text("A", v).unwrap();
assert_eq!(s.tool_ime_target(v), Some(first), "ordinary typing keeps the owner");
key(&mut s, ToolKey::Left);
assert_eq!(s.tool_ime_target(v), Some(first), "caret moves keep the owner");
key(&mut s, ToolKey::Escape);
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
let second = s.tool_ime_target(v).expect("the new document's type has a target");
assert_ne!(first.0, second.0, "a document replacement is a new IME owner");
}
/// The outgoing surrounding-text source excludes the pre-edit and reports UTF-8 byte offsets.
#[test]
fn ime_surrounding_excludes_the_preedit_and_remaps_offsets() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Hé", v).unwrap();
s.tool_preedit("xy", None, v).unwrap();
let vectorcraft_tools::ImeSurrounding::Available { text, cursor, anchor } = s.tool_ime_surrounding(4000, v) else {
panic!("type editing has a context");
};
assert_eq!(text, "Hé");
assert_eq!((cursor, anchor), (3, 3), "the caret sits after the committed text, before the pre-edit");
}
+76
View File
@@ -473,6 +473,54 @@ fn canceling_undo_groups_keeps_the_journal_entries_made_outside_them() {
assert_eq!(r.active_index(), s.active_index());
}
/// A batch that edits the canceled group's document and then a document without a group keeps the
/// steps outside the group in the journal, so a replay draws the edit the second document keeps.
#[test]
fn canceling_an_undo_group_keeps_a_batchs_steps_in_other_documents() {
let mut s = Session::new();
s.execute("file.new", &json!({"created": null})).unwrap();
s.execute("file.new", &json!({"created": null})).unwrap();
let objects = |s: &Session| s.documents().iter().map(|d| d.doc.node_count()).collect::<Vec<_>>();
let before = objects(&s);
let steps = json!([rect_step(10), rect_step(20), {"command": "document.activate", "params": {"index": 1}}, rect_step(30)]);
for (id, p) in [
("document.activate", json!({"index": 0})),
("edit.undoGroup.begin", json!({})),
("command.batch", json!({ "commands": steps })),
("shape.rectangle", rect_step(50)["params"].clone()),
("edit.undoGroup.end", json!({"cancel": true})),
] {
s.execute(id, &p).unwrap();
}
assert_eq!(objects(&s), vec![before[0], before[1] + 2], "only the group's edits are undone");
let (_, batch) = s.journal.iter().find(|(id, _)| id == "command.batch").unwrap();
assert_eq!(batch["commands"], json!([steps[2], steps[3]]));
let r = replayed(&s);
assert_eq!(documents(&r), documents(&s));
assert_eq!(r.active_index(), s.active_index());
// Undo scopes in two documents canceled one at a time: the second cancel finds its own step after
// the first one took a step out of the batch, and the third document's edit stays.
s.execute("file.new", &json!({"created": null})).unwrap();
let before = objects(&s);
assert!(s.set_active(0));
let first = s.begin_undo_scope().unwrap();
assert!(s.set_active(1));
let second = s.begin_undo_scope().unwrap();
assert!(s.set_active(0));
let activate = |index: usize| json!({"command": "document.activate", "params": {"index": index}});
let steps = json!([rect_step(10), activate(1), rect_step(30), activate(2), rect_step(50)]);
s.execute("command.batch", &json!({ "commands": steps })).unwrap();
s.end_undo_scope(first, true);
s.end_undo_scope(second, true);
assert_eq!(objects(&s), vec![before[0], before[1], before[2] + 1]);
let (_, batch) = s.journal.iter().rev().find(|(id, _)| id == "command.batch").unwrap();
assert_eq!(batch["commands"], json!([steps[1], steps[3], steps[4]]));
let r = replayed(&s);
assert_eq!(documents(&r), documents(&s));
assert_eq!(r.active_index(), s.active_index());
}
/// The edits made in an undo group (a scrubbed numeric field, #400) are one undo step, journaled
/// one by one; a cancelled group undoes them and leaves the journal as it was.
#[test]
@@ -732,6 +780,34 @@ fn an_undo_scope_in_an_open_group_takes_back_only_its_own_edits() {
assert_eq!(objects(&s), 0, "one undo takes back the group's edits");
}
/// A canceled undo scope that joined an open group forgets the journal entries it cut: when a command
/// in another document takes the same entry number, a later cancel of the group keeps that entry, and
/// the journal replays to the same documents.
#[test]
fn a_canceled_undo_scope_leaves_its_cut_entries_out_of_the_group() {
let mut s = Session::new();
let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50});
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.begin_undo_group();
s.execute("shape.rectangle", &rect).unwrap();
let scope = s.begin_undo_scope().unwrap();
s.execute("shape.rectangle", &rect).unwrap();
s.end_undo_scope(scope, true);
let journal = s.journal.len();
// The switch takes the entry number the scope's edit had, and the rectangle in the second document follows it.
s.execute("document.activate", &json!({"index": 1})).unwrap();
s.execute("shape.rectangle", &rect).unwrap();
s.end_undo_group(true);
let objects = s.documents().iter().map(|d| d.doc.layers[0].children().map_or(0, |c| c.len())).collect::<Vec<_>>();
assert_eq!(objects, [0, 1]);
assert_eq!(s.journal.len(), journal + 1, "only the group's own edit leaves the journal");
let r = replayed(&s);
assert_eq!(documents(&r), documents(&s));
assert_eq!(r.active_index(), s.active_index());
}
/// A move drag is named for what its last preview did: Alt pressed or released during the drag
/// turns it into a copy or back (the Selection and Direct Selection tools).
#[test]
+28 -12
View File
@@ -20,8 +20,14 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
/// placing and listing read).
pub(crate) fn png_header(w: u32, h: u32) -> Vec<u8> {
png_header_as(w, h, [1, 0, 0, 0, 0])
}
/// A PNG that says it is `w` × `h` pixels of the bit depth, color type, compression, filter and
/// interlace method `kind` gives (its header and no rows).
fn png_header_as(w: u32, h: u32, kind: [u8; 5]) -> Vec<u8> {
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &kind].concat();
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
out.extend((chunk.len() as u32 - 4).to_be_bytes());
out.extend(chunk);
@@ -30,32 +36,42 @@ pub(crate) fn png_header(w: u32, h: u32) -> Vec<u8> {
out
}
/// An image past what a decode may take isn't drawn: Place and Open say so and the Links panel's
/// row carries the note, where the image used to be blank with no word (#1479).
/// An image past what a decode may take is drawn at reduced size: Place and Open say so and the
/// Links panel's row carries the note. One the renderer can't read at reduced size (an interlaced
/// RGBA PNG past the allowance as decoded, or an image of more pixels than the allowance has
/// bytes) isn't drawn, and the note says it is too large to draw (#1479).
#[test]
fn an_image_too_large_to_draw_is_reported() {
const NOTE: &str = "an image of 200000 × 200000 pixels is too large to draw";
const NOTE: &str = "an image of 40000 × 40000 pixels is shown at reduced size";
const UNDRAWN: &str = "an image of 30000 × 30000 pixels is too large to draw";
let dir = Folder::new("huge");
let (big, small) = (dir.file("wall.png"), dir.file("small.png"));
write(&big, &png_header(200_000, 200_000));
let (big, small, laced) = (dir.file("wall.png"), dir.file("small.png"), dir.file("laced.png"));
write(&big, &png_header(40_000, 40_000));
write(&small, &png(40, 20, BLUE));
write(&laced, &png_header_as(30_000, 30_000, [8, 6, 0, 0, 1]));
let mut s = session();
assert_eq!(run(&mut s, "file.place", json!({"path": big, "at": [200, 150], "link": false}))["warnings"], json!([NOTE]));
assert_eq!(run(&mut s, "file.place", json!({"path": small, "at": [20, 20], "link": false}))["warnings"], json!([]));
assert_eq!(run(&mut s, "file.place", json!({"path": laced, "at": [20, 20], "link": false}))["warnings"], json!([UNDRAWN]));
let l = run(&mut s, "links.list", json!({}));
let notes: Vec<&Value> = l["links"].as_array().unwrap().iter().map(|r| &r["note"]).collect();
assert_eq!(notes, [&Value::Null, &json!(NOTE)], "top first: the small image has nothing to say");
assert_eq!(notes, [&json!(UNDRAWN), &Value::Null, &json!(NOTE)], "top first: the small image has nothing to say");
// Opened as a document: said too. An image that decodes (16,000 px square) has no note.
assert_eq!(crate::cmd::fileio::load("wall.png", &png_header(200_000, 200_000)).unwrap().warnings, [NOTE]);
assert_eq!(crate::cmd::fileio::load("wall.png", &png_header(40_000, 40_000)).unwrap().warnings, [NOTE]);
let more = crate::cmd::fileio::load("more.png", &png_header(200_000, 200_000)).unwrap().warnings;
assert_eq!(more, ["an image of 200000 × 200000 pixels is too large to draw"], "more pixels than the allowance has bytes");
assert!(crate::cmd::fileio::load("fine.png", &png_header(16_000, 16_000)).unwrap().warnings.is_empty());
// 20,000 px square at 16 bits a sample is 3.2 GB decoded and 1.6 GB as RGBA: read a row at a
// time at its own size, so nothing is said of it.
assert!(crate::cmd::fileio::load("deep.png", &png_header_as(20_000, 20_000, [16, 6, 0, 0, 0])).unwrap().warnings.is_empty());
}
/// A gray image is counted as RGBA, 4 bytes a pixel: 23,171 × 23,170 1-bit pixels decode to 537 MB
/// of gray and are 2,147,488,280 bytes as RGBA, past the 2 GiB allowance; 23,170 × 23,170 are
/// 2,147,395,600 bytes, within it.
/// of gray and are 2,147,488,280 bytes as RGBA, past the 2 GiB allowance (shown at reduced size);
/// 23,170 × 23,170 are 2,147,395,600 bytes, within it.
#[test]
fn a_gray_image_too_large_as_rgba_is_reported_on_place() {
const NOTE: &str = "an image of 23171 × 23170 pixels is too large to draw";
const NOTE: &str = "an image of 23171 × 23170 pixels is shown at reduced size";
let dir = Folder::new("huge-gray-place");
let (over, within) = (dir.file("over.png"), dir.file("within.png"));
write(&over, &png_header(23_171, 23_170));
@@ -76,7 +92,7 @@ fn a_gray_image_too_large_as_rgba_is_reported_on_open() {
write(&over, &png_header(23_171, 23_170));
write(&within, &png_header(23_170, 23_170));
let mut s = session();
assert_eq!(open(&mut s, &over)["warnings"], json!(["an image of 23171 × 23170 pixels is too large to draw"]));
assert_eq!(open(&mut s, &over)["warnings"], json!(["an image of 23171 × 23170 pixels is shown at reduced size"]));
assert_eq!(open(&mut s, &within)["warnings"], json!([]));
}
fn close(a: f64, b: f64) -> bool {
+119
View File
@@ -1144,3 +1144,122 @@ fn stroke_advanced_scale_swap_flip() {
assert!(s.execute("stroke.setAdvanced", &json!({})).is_err());
assert!(s.execute("stroke.setAdvanced", &json!({"flipProfile": "sideways"})).is_err());
}
/// A document of `n` 200 × 150 artboards in a row, 400 pt apart (`x` = 400·i), each with a
/// guide of its own.
fn row_of_artboards(n: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
for i in 1..n {
s.execute("artboard.new", &json!({"x": 400 * i, "y": 0, "width": 200, "height": 150})).unwrap();
}
for i in 0..n {
s.execute("guide.add", &json!({"vertical": true, "pos": 400 * i + 50, "artboard": i})).unwrap();
}
s
}
fn box_at(s: &mut Session, x: f64, w: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 20})).unwrap()["id"].as_u64().unwrap())
}
fn artboard_names(s: &Session) -> Vec<String> {
s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect()
}
/// Artboards panel › Delete Empty Artboards: with art only on artboard 2, artboards 1 and 3 go
/// with their guides in one undo step, the art and its ids untouched; Undo brings them back.
#[test]
fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() {
let mut s = row_of_artboards(3);
let art = box_at(&mut s, 450.0, 20.0);
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}));
let st = s.doc().unwrap();
assert_eq!(artboard_names(&s), ["Artboard 2"]);
assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them");
assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays");
assert_eq!(st.history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]);
assert_eq!(s.doc().unwrap().doc.guides.len(), 3);
}
/// What counts as art: hidden and locked objects, an object straddling two artboards and one
/// that only touches an artboard's edge; a group counts by its members, not by its box.
#[test]
fn delete_empty_artboards_keeps_artboards_with_hidden_locked_or_shared_art() {
let mut s = row_of_artboards(5);
// Hidden art on 1, locked art on 2.
box_at(&mut s, 10.0, 20.0);
s.execute("object.hide", &json!({})).unwrap();
box_at(&mut s, 410.0, 20.0);
s.execute("object.lock", &json!({})).unwrap();
// Straddling 3 (x 800–1000) and 4 (x 1200–1400).
box_at(&mut s, 900.0, 350.0);
// A group of a box on 3 and one whose right edge touches 6's left edge (x 2000): its box spans
// 5 (x 1600–1800), which stays empty, and 6 has art only by the touch.
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 200, "height": 150})).unwrap();
let a = box_at(&mut s, 850.0, 20.0);
let b = box_at(&mut s, 1980.0, 20.0);
// No stroke, so its visual box ends at x 2000.
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [4]}));
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4", "Artboard 6"]);
}
/// A locked empty artboard stays; with every artboard empty artboard 1 stays; with none empty
/// the command is disabled, refuses and adds no undo step.
#[test]
fn delete_empty_artboards_leaves_locked_ones_and_artboard_1() {
let mut s = row_of_artboards(3);
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}), "all empty, one locked");
assert_eq!(artboard_names(&s), ["Artboard 2"]);
let mut s = row_of_artboards(3);
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 2]}), "all empty: artboard 1 stays");
assert_eq!(artboard_names(&s), ["Artboard 1"]);
let steps = s.doc().unwrap().history.undo.len();
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
assert!((spec.enabled)(&s).is_err(), "one artboard left: nothing to delete");
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err());
box_at(&mut s, 10.0, 20.0);
let steps_with_art = s.doc().unwrap().history.undo.len();
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err(), "nothing empty");
assert_eq!(s.doc().unwrap().history.undo.len(), steps_with_art);
assert!(steps_with_art > steps);
}
/// Art outside a clip shows nowhere: a clip group or a clipping layer has art only where its
/// members show inside the clip, so an artboard that only the clipped-away part of a member
/// covers is empty.
#[test]
fn delete_empty_artboards_measures_clipped_art_through_the_clip() {
// Clip group: the clip on 1, a member running on to 2. Clipping layer: the clip on 3, a member
// running on to 4. Nothing else.
let mut s = row_of_artboards(4);
let long = box_at(&mut s, 50.0, 400.0);
let clip = box_at(&mut s, 10.0, 100.0);
// The front object clips (drawn after the member).
s.execute("select.set", &json!({"ids": [long.0, clip.0]})).unwrap();
s.execute("object.clippingMask.make", &json!({})).unwrap();
s.execute("layer.new", &json!({"name": "Clipped"})).unwrap();
box_at(&mut s, 850.0, 400.0);
// The layer's top object becomes its clipping path.
let layer_clip = box_at(&mut s, 810.0, 100.0);
let layer_of =
|s: &Session| s.doc().unwrap().doc.layers.iter().find(|l| l.children().unwrap().iter().any(|c| c.id == layer_clip)).unwrap().clone();
let layer = layer_of(&s);
assert_eq!(layer.children().unwrap().len(), 2, "both in their own layer");
s.execute("layer.clippingMask.toggle", &json!({"id": layer.id.0})).unwrap();
let layer = layer_of(&s);
assert!(matches!(layer.kind, vectorcraft_doc::NodeKind::Layer { clip: true, .. }));
let first = layer.children().unwrap()[0].id;
assert_eq!(first, layer_clip, "the layer's first object clips the rest");
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 3]}));
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 3"]);
}
+184
View File
@@ -1098,3 +1098,187 @@ fn create_outlines_turns_colr_glyphs_into_layer_groups() {
let mut seen = std::collections::HashSet::new();
assert!(nodes.iter().all(|(id, ..)| d.node(*id).is_some() && seen.insert(*id)), "ids of the document's, each once");
}
/// Pathfinder panel › Pathfinder Options: `object.pathfinder.options` reads and sets the options
/// (kept in the preferences as the `pathfinder` group) and refuses values out of range or of the
/// wrong type, changing nothing.
#[test]
fn pathfinder_options_round_trip_and_refuse_bad_values() {
let mut s = session();
let defaults = json!({"precision": 0.01, "removeRedundantPoints": false, "removeUnpainted": false});
assert_eq!(s.execute("object.pathfinder.options", &json!({})).unwrap(), defaults);
let set = json!({"precision": 0.5, "removeRedundantPoints": true, "removeUnpainted": true});
assert_eq!(s.execute("object.pathfinder.options", &set).unwrap(), set);
assert_eq!(s.execute("prefs.get", &json!({"key": "pathfinder"})).unwrap(), set);
let mm = s.execute("object.pathfinder.options", &json!({"precision": "1 mm"})).unwrap()["precision"].as_f64().unwrap();
assert!((mm - 72.0 / 25.4).abs() < 1e-9, "a length with a unit: {mm}");
s.execute("object.pathfinder.options", &json!({"precision": 0.5})).unwrap();
for bad in [
json!({"precision": 0}),
json!({"precision": 11}),
json!({"precision": "x"}),
json!({"precision": -1}),
json!({"removeRedundantPoints": "yes"}),
json!({"removeUnpainted": 1}),
json!({"tolerance": 1}),
] {
assert!(s.execute("object.pathfinder.options", &bad).is_err(), "{bad}");
assert!(s.execute("prefs.set", &json!({"key": "pathfinder", "value": bad})).is_err(), "prefs.set {bad}");
assert_eq!(s.execute("object.pathfinder.options", &json!({})).unwrap(), set, "{bad} changes nothing");
}
s.execute("prefs.set", &json!({"key": "pathfinder", "value": {"removeUnpainted": false}})).unwrap();
assert!(!s.prefs.pathfinder.remove_unpainted);
assert!(s.prefs.pathfinder.remove_redundant_points, "a partial object keeps the rest");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.execute("object.pathfinder.options", &json!({})).unwrap(), defaults);
}
/// A filled square and, crossing it, a rectangle with no fill, selected.
fn filled_and_unfilled(s: &mut Session) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let b = rect(s, 50.0, 25.0, 100.0, 50.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
s.edit("t", |d, _| {
d.node_mut(b).unwrap().appearance.set_fill(Paint::None);
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
}
/// The fill of each piece of the group `op` makes from [`filled_and_unfilled`], and of each
/// piece's stroke.
fn pieces(s: &mut Session, op: &str) -> Vec<(Paint, Paint)> {
let r = ids(&s.execute(op, &json!({})).unwrap());
node(s, r[0]).children().unwrap().iter().map(|c| (c.appearance.fill_paint(), c.appearance.stroke_paint())).collect()
}
/// Pathfinder Options › Divide and Outline Will Remove Unpainted Artwork: off, Divide and Outline
/// keep the pieces of an object with no fill; on, they leave them out.
#[test]
fn divide_and_outline_remove_unpainted_artwork_when_asked() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
for on in [false, true] {
let mut s = session();
s.execute("object.pathfinder.options", &json!({"removeUnpainted": on})).unwrap();
filled_and_unfilled(&mut s);
let divided = pieces(&mut s, "object.pathfinder.divide");
let unfilled = divided.iter().filter(|(fill, _)| fill.is_none()).count();
// The overlap takes the paint of the rectangle in front, so it has no fill either.
assert_eq!(unfilled, if on { 0 } else { 2 }, "Divide, option {on}: {divided:?}");
assert_eq!(divided.len() - unfilled, 1, "the square's piece stays");
s.execute("edit.undo", &json!({})).unwrap();
let outlined = pieces(&mut s, "object.pathfinder.outline");
let from_unfilled = outlined.iter().filter(|(_, stroke)| *stroke != red).count();
assert_eq!(from_unfilled > 0, !on, "Outline, option {on}: {outlined:?}");
assert!(outlined.iter().any(|(_, stroke)| *stroke == red), "the square's edges stay");
}
}
/// Pathfinder Options › Remove Redundant Points: Unite of a square with an anchor in the middle of
/// its top edge and a square inside it keeps the extra anchor unless the option is on.
#[test]
fn remove_redundant_points_drops_anchors_that_shape_nothing() {
for on in [false, true] {
let mut s = session();
s.execute("object.pathfinder.options", &json!({"removeRedundantPoints": on})).unwrap();
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
let a = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap());
let b = rect(&mut s, 20.0, 20.0, 60.0, 60.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = ids(&s.execute("object.pathfinder.unite", &json!({})).unwrap());
let anchors = path_of(&s, r[0]).0.anchor_count();
if on {
assert_eq!(anchors, 4, "option on");
} else {
assert!(anchors > 4, "option off keeps the anchor: {anchors}");
}
assert!((area_of(&s, r[0]) - 10_000.0).abs() < 1e-6);
}
}
/// Pathfinder Options › Precision: Divide drops a 0.5 pt overlap at a precision of 1 pt and keeps
/// it at the default.
#[test]
fn precision_decides_whether_a_thin_overlap_is_a_piece() {
for (precision, faces) in [(None, 3), (Some(1.0), 2)] {
let mut s = session();
if let Some(p) = precision {
s.execute("object.pathfinder.options", &json!({"precision": p})).unwrap();
}
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 0.0, 99.5, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap());
assert_eq!(node(&s, r[0]).children().unwrap().len(), faces, "precision {precision:?}");
}
}
/// A hidden fill paints nothing: with Divide and Outline Will Remove Unpainted Artwork on, the
/// pieces of an object whose only fill is hidden go too.
#[test]
fn remove_unpainted_counts_a_hidden_fill_as_unpainted() {
let mut s = session();
s.execute("object.pathfinder.options", &json!({"removeUnpainted": true})).unwrap();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 50.0, 25.0, 100.0, 50.0);
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
set_fill(&mut s, b, Color::rgb(0.0, 0.0, 1.0));
s.edit("t", |d, _| {
d.node_mut(b).unwrap().appearance.fill_mut().unwrap().visible = false;
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let divided = pieces(&mut s, "object.pathfinder.divide");
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
assert_eq!(divided.len(), 1, "only the red square's piece: {divided:?}");
assert_eq!(divided[0].0, red);
}
/// Shape Modes unite a group selected with other objects first: at a fine precision a group
/// member thinner than the default precision (0.005 pt) is kept there too.
#[test]
fn a_thin_group_member_survives_the_group_union_at_a_fine_precision() {
for (precision, subpaths) in [(None, 2), (Some(0.001), 3)] {
let mut s = session();
if let Some(p) = precision {
s.execute("object.pathfinder.options", &json!({"precision": p})).unwrap();
}
let (a, thin) = (rect(&mut s, 0.0, 0.0, 50.0, 50.0), rect(&mut s, 0.0, 100.0, 100.0, 0.005));
s.execute("select.set", &json!({"ids": [a.0, thin.0]})).unwrap();
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let other = rect(&mut s, 200.0, 0.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [g, other.0]})).unwrap();
let r = ids(&s.execute("object.pathfinder.unite", &json!({})).unwrap());
assert_eq!(path_of(&s, r[0]).0.subpaths.len(), subpaths, "precision {precision:?}");
}
}
/// A Pathfinder operation records the options it used in its journal entry, so replaying the
/// journal with other preferences gives the same art.
#[test]
fn pathfinder_journal_entries_replay_with_the_options_they_used() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 99.5, "width": 100, "height": 50})).unwrap();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.prefs.pathfinder = PathfinderOptions { precision: 1.0, remove_redundant_points: true, remove_unpainted: true };
let r = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap());
assert_eq!(node(&s, r[0]).children().unwrap().len(), 1, "the thin overlap and the unfilled piece go");
let (id, p) = s.journal.last().unwrap().clone();
assert_eq!(id, "object.pathfinder.divide");
assert_eq!((&p["precision"], &p["removeRedundantPoints"], &p["removeUnpainted"]), (&json!(1.0), &json!(true), &json!(true)));
let mut replay = Session::new();
for (id, p) in &s.journal {
replay.execute(id, p).unwrap();
}
assert_eq!(replay.prefs.pathfinder, PathfinderOptions::default());
let doc = |s: &Session| serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
assert_eq!(doc(&replay), doc(&s));
// Options given as params are checked as object.pathfinder.options checks them.
assert!(s.execute("object.pathfinder.divide", &json!({"precision": 20})).is_err());
}
+25
View File
@@ -252,3 +252,28 @@ fn dragging_the_start_and_end_brackets_sets_where_the_type_runs() {
let r = ink(&s, id);
assert!(r.x0 > 199.0 && r.x1 < 401.0, "between the brackets: {r:?}");
}
/// #3980: Direct Selection drags an anchor of type on a path's path, in document space through
/// the type's transform; the brackets keep their fractions of the path and the type reflows.
#[test]
fn direct_selection_drags_an_anchor_of_the_path_and_the_type_reflows() {
let mut s = session();
let id = path_type(&mut s, "M100 300 L300 300 L500 300", [100.0, 310.0], "Reflowing type");
s.execute("type.pathOptions", &json!({"start": 0.0, "end": 0.75})).unwrap();
// A quarter turn, so a document-space move isn't the path's own.
s.execute("object.transform", &json!({"ids": [id.0], "matrix": [0, 1, -1, 0, 600, 0]})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let path = |s: &Session| text(s, id).type_path().unwrap();
let (first, middle) = (path(&s).subpaths[0].anchors[0].p, path(&s).subpaths[0].anchors[1].p);
let (glyphs, n) = (origins(&s, id), undo_len(&s));
drag(&mut s, "directSelection", middle, middle + vectorcraft_geom::Vec2::new(40.0, 0.0), Mods::default());
let after = path(&s);
let moved = after.subpaths[0].anchors[1].p;
assert!(near(moved.x, middle.x + 40.0) && near(moved.y, middle.y), "{middle:?} → {moved:?}");
assert_eq!(after.subpaths[0].anchors[0].p, first, "the other anchors stay");
assert_eq!(brackets(&s, id), (0.0, Some(0.75)));
assert_ne!(origins(&s, id), glyphs, "the type reflows along the reshaped path");
assert_eq!((undo_len(&s), last_step(&s).as_str()), (n + 1, "Reshape Type Path"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s).subpaths[0].anchors[1].p, middle);
}
+39 -1
View File
@@ -417,7 +417,7 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
("paintbrush", Cursor::Paintbrush),
("blobBrush", Cursor::BlobBrush),
("shaper", Cursor::Shaper),
("pencil", Cursor::Pencil),
("pencil", Cursor::PencilNew),
("smooth", Cursor::Smooth),
("pathEraser", Cursor::PathEraser),
("join", Cursor::Join),
@@ -1454,6 +1454,25 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() {
assert!(!s.prefs.highlight_alternate_glyphs);
}
/// Preferences › Type › Highlight Substituted Fonts: on by default, listed in the Type category,
/// and saved with the other preferences.
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html>
#[test]
fn highlight_substituted_fonts_preference_is_on_by_default_and_saved() {
let mut s = Session::new();
assert!(s.prefs.highlight_substituted_fonts, "on by default");
let spec = crate::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == "highlightSubstitutedFonts").expect("listed");
assert_eq!((spec.category, spec.label), ("Type", "Highlight Substituted Fonts"));
assert_eq!(s.execute("prefs.get", &json!({"key": "highlightSubstitutedFonts"})).unwrap(), json!(true));
set_pref(&mut s, "highlightSubstitutedFonts", json!(false));
assert!(!s.prefs.highlight_substituted_fonts);
let saved: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert!(!saved.highlight_substituted_fonts);
// Preferences saved before the key existed read it as on.
let old: Prefs = serde_json::from_value(json!({"highlightAlternateGlyphs": true})).unwrap();
assert!(old.highlight_substituted_fonts);
}
/// Preferences › Performance › Real-time Drawing and Editing (#394): off, the document isn't
/// previewed while dragging but the pending shape still shows as a canvas overlay until commit.
#[test]
@@ -1722,3 +1741,22 @@ fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() {
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// The Transform panel menu's choice starts as `default` (the panels follow General › Transform
/// Pattern Tiles): in a new session, in a preferences file saved before the choice existed and
/// after a reset; `prefs.set` takes it and the three menu choices, and nothing else.
#[test]
fn transform_panel_patterns_defaults_to_following_the_general_preference() {
assert_eq!(Prefs::default().transform_panel_patterns, "default");
let old: Prefs = serde_json::from_value(json!({"transformPatternTiles": true, "scaleStrokes": true})).unwrap();
assert_eq!((old.transform_panel_patterns.as_str(), old.transform_pattern_tiles), ("default", true));
let mut s = Session::new();
for v in ["object", "pattern", "both", "default"] {
s.execute("prefs.set", &json!({"key": "transformPanelPatterns", "value": v})).unwrap();
assert_eq!(s.prefs.transform_panel_patterns, v);
}
assert!(s.execute("prefs.set", &json!({"key": "transformPanelPatterns", "value": "tiles"})).is_err());
s.execute("prefs.set", &json!({"key": "transformPanelPatterns", "value": "both"})).unwrap();
s.execute("prefs.reset", &json!({"category": "General"})).unwrap();
assert_eq!(s.prefs.transform_panel_patterns, "default");
}
+27
View File
@@ -138,6 +138,33 @@ fn a_crash_leaves_copies_that_restore_modified_and_titled() {
assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy");
}
/// A threaded frame whose first paragraph continues from the frame before is restored without
/// that paragraph's first-line indent.
#[test]
fn a_restored_copy_keeps_continued_paragraphs_in_threaded_frames() {
let store = Arc::new(MemoryStore::default());
let mut s = relaunch(&store);
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame of this story. ".repeat(4);
let a =
s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "leftIndent": 0, "firstLineIndent": 24})).unwrap();
let continues = |s: &Session| match &s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(b)).unwrap().kind {
vectorcraft_doc::NodeKind::Text(t) => t.first_para_continues,
_ => panic!("not text"),
};
assert!(continues(&s));
s.execute("file.recovery.save", &json!({})).unwrap();
drop(s);
let mut s = relaunch(&store);
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert!(continues(&s), "the second frame continues the first frame's paragraph");
}
#[test]
fn a_restored_copy_still_refuses_to_replace_the_file_an_import_left_things_out_of() {
let (mut s, store) = session();
+187
View File
@@ -545,6 +545,164 @@ fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
assert_eq!(obj(&s, id).plain_text(), "abc!");
}
fn list_markers(s: &Session, id: NodeId) -> Vec<String> {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id)).markers.iter().map(|m| m.text.clone()).collect()
}
/// Typed list detection: at the start of a paragraph, "1.", "1)", "A." or "A)" and a space start
/// a numbered list, and "*", "#", ">" or "+" and a space start a bulleted list with the filled dot.
/// The typed characters go, the paragraph takes the hanging indent, and the text typed next is the
/// list item's. One Undo puts the typed characters back with no list, and typing goes on after
/// them, also after a Backspace that deleted nothing.
#[test]
fn typing_a_list_start_and_a_space_starts_a_list() {
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
let v = ViewInfo::default();
let (arabic, letters, dot) =
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
let cases = [("1.", arabic, "1."), ("1)", arabic, "1."), ("A.", letters, "A."), ("A)", letters, "A.")];
let bullets = ["*", "#", ">", "+"].map(|b| (b, dot, "\u{2022}"));
for (typed, list, marker) in cases.into_iter().chain(bullets) {
let mut s = session();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
for c in typed.chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
s.tool_text(" ", v).unwrap();
let t = obj(&s, id);
assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("", Some(list)), "{typed}");
assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (18.0, -18.0), "{typed}");
assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Bullets and Numbering"), "{typed}");
s.tool_text("Apples", v).unwrap();
assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(0).list), ("Apples", Some(list)), "{typed}");
assert_eq!(list_markers(&s, id), [marker], "{typed}");
key(&mut s, ToolKey::Enter, Mods::default());
s.tool_text("Pears", v).unwrap();
assert_eq!(obj(&s, id).para_at(1).list, Some(list), "Return continues the list: {typed}");
// One Undo after the space puts the characters back with no list; typing goes on after them.
let mut s = session();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
for c in typed.chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
s.tool_text(" ", v).unwrap();
// A Backspace at the start of the empty item deletes nothing and leaves that as it is.
key(&mut s, ToolKey::Backspace, Mods::default());
s.execute("edit.undo", &json!({})).unwrap();
let t = obj(&s, id);
assert_eq!((t.plain_text(), t.para_at(0).list), (format!("{typed} "), None), "{typed}");
assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (0.0, 0.0), "{typed}");
s.tool_text("x", v).unwrap();
assert_eq!((obj(&s, id).plain_text(), obj(&s, id).para_at(0).list), (format!("{typed} x"), None), "{typed}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "", "the typing before the list is a step of its own: {typed}");
}
}
/// A list typed before a paragraph's text: the caret stays before the text, goes after the typed
/// characters when Undo puts them back, also after undoing what was typed after the list, and
/// before the text again after Redo, also when the text starts with the same characters.
#[test]
fn the_caret_follows_undo_and_redo_of_a_typed_list() {
let v = ViewInfo::default();
for start in ["Apples", "* Apples"] {
let mut s = session();
let id = text(&mut s, start);
s.select_tool("type", v).unwrap();
click(&mut s, 101.0, 95.0);
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
for c in "* ".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
let state = |s: &Session| (obj(s, id).plain_text(), s.tool_options()["caret"].clone());
assert_eq!(state(&s), (start.to_string(), json!(0)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(state(&s), (format!("* {start}"), json!(2)));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(state(&s), (start.to_string(), json!(0)));
s.tool_text("x", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), format!("x{start}"));
// Undo of the typing and then of the list: typing goes on after the characters again.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.tool_text("y", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), format!("* y{start}"));
}
}
/// The same characters later in a paragraph, without the space, typed in one piece with the space
/// (a paste), or at the start of a paragraph that is already in a list start nothing.
#[test]
fn list_starts_typed_elsewhere_or_pasted_start_no_list() {
use vectorcraft_doc::text::{NumberStyle, ParaList};
let v = ViewInfo::default();
let mut s = session();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
for c in "Fruit 1. * A) x".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
key(&mut s, ToolKey::Enter, Mods::default());
s.tool_text("1. Apples", v).unwrap();
key(&mut s, ToolKey::Enter, Mods::default());
for c in "1.Pears".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
key(&mut s, ToolKey::Enter, Mods::default());
s.tool_text("* ", v).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* ");
assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [None, None, None, None]);
assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"));
// A paragraph in a list keeps its list, and the typed characters stay as text.
let id = text(&mut s, "Item");
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "number"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
click(&mut s, 101.0, 95.0);
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
for c in "* ".chars() {
s.tool_text(&c.to_string(), v).unwrap();
}
let t = obj(&s, id);
assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("* Item", Some(ParaList::numbered(NumberStyle::Arabic))));
}
/// `text.startList`, which the Type tool runs, works from scripts too: by default it reads the
/// text up to the end, and it changes nothing (and records no step) when the text there starts no
/// list.
#[test]
fn start_list_command_removes_the_typed_characters_and_starts_the_list() {
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
let mut s = session();
let id = text(&mut s, "Fruit\n1) Apples");
let r = s.execute("text.startList", &json!({"id": id.0, "at": 9})).unwrap();
assert_eq!(r, json!({"id": id.0, "started": true, "caret": 6}));
let t = obj(&s, id);
assert_eq!(
(t.plain_text().as_str(), t.para_at(0).list, t.para_at(1).list),
("Fruit\nApples", None, Some(ParaList::numbered(NumberStyle::Arabic)))
);
assert_eq!(list_markers(&s, id), ["1."]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(1).list), ("Fruit\n1) Apples", None));
let id = text(&mut s, "x\n# ");
let r = s.execute("text.startList", &json!({"id": id.0})).unwrap();
assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()));
assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Dot)));
let steps = undo_labels(&s).len();
let id = text(&mut s, "1.x");
assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap()["started"], json!(false));
assert_eq!(undo_labels(&s).len(), steps + 1, "only text.create's step");
assert!(s.execute("text.startList", &json!({"id": id.0, "at": "3"})).is_err(), "`at` is a number");
assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required");
}
/// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type
/// and keeps what was typed, and typing goes on after it.
#[test]
@@ -1269,3 +1427,32 @@ fn clicking_the_in_port_then_the_canvas_threads_a_new_frame_before_it() {
let strokes = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { .. })).count();
assert_eq!(strokes, 6, "both frames selected: the first one's out port and the second one's in port show arrows");
}
/// Given a family without a style, text.create, text.createInPath and text.setStyle keep the style
/// when the family has it, else take the family's closest one, as text.setRangeStyle does: a family
/// whose styles are numbered has no Regular to store (#4128). A style given is kept as given.
#[test]
fn a_family_without_its_style_takes_its_closest_style() {
use vectorcraft_text::test_fonts::{NUMBERED_FAMILY as FAMILY, numbered_font};
let db = vectorcraft_text::FontDb::global();
db.add_font(numbered_font("W3").unwrap());
db.add_font(numbered_font("W6").unwrap());
let numbered = ["W3", "W6"];
let style = |s: &Session, id: NodeId| obj(s, id).runs[0].style.font_style.clone();
let create = |s: &mut Session, p: Value| NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap());
let mut s = session();
let created = create(&mut s, json!({"x": 10, "y": 50, "text": "a", "font": FAMILY}));
assert!(numbered.contains(&style(&s, created).as_str()), "text.create: {}", style(&s, created));
let pid = s.execute("path.create", &json!({"d": "M100 400 L400 100"})).unwrap()["id"].as_u64().unwrap();
let on_path = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "a", "font": FAMILY})).unwrap();
let on_path = NodeId(on_path["id"].as_u64().unwrap());
assert!(numbered.contains(&style(&s, on_path).as_str()), "text.createInPath: {}", style(&s, on_path));
let given = create(&mut s, json!({"x": 10, "y": 90, "text": "a", "font": FAMILY, "style": "Medium"}));
assert_eq!(style(&s, given), "Medium", "a style given is kept");
let set = text(&mut s, "a");
s.execute("text.setStyle", &json!({"ids": [set.0], "font": FAMILY})).unwrap();
assert!(numbered.contains(&style(&s, set).as_str()), "text.setStyle: {}", style(&s, set));
s.execute("text.setStyle", &json!({"ids": [set.0], "style": "W6"})).unwrap();
s.execute("text.setStyle", &json!({"ids": [set.0], "font": FAMILY})).unwrap();
assert_eq!(style(&s, set), "W6", "a style the family has is kept");
}
+82 -2
View File
@@ -5,10 +5,14 @@ use vectorcraft_doc::Selection;
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::perspective::widget::WidgetPlace;
use vectorcraft_tools::draw2::FEEDBACK;
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
use vectorcraft_tools::shape::MAX_CORNER_RADIUS;
use vectorcraft_tools::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings};
use crate::{EngineError, Prefs, Result, Session};
/// The tool whose radius is General › Corner Radius.
const ROUNDED_RECTANGLE: &str = "roundedRectangle";
/// View state the tools need from the frontend.
#[derive(Clone, Copy, Debug)]
pub struct ViewInfo {
@@ -186,6 +190,10 @@ impl Session {
pub(crate) fn make_tool(&self, id: &str) -> Box<dyn Tool> {
let mut t = vectorcraft_tools::create(id);
Self::restore_tool_settings(&self.prefs, t.as_mut());
// General › Corner Radius is the Rounded Rectangle tool's radius (#394).
if id == ROUNDED_RECTANGLE {
t.set_option("cornerRadius", &Value::from(self.prefs.corner_radius));
}
t
}
@@ -204,6 +212,14 @@ impl Session {
/// Store the persistent options of `t` in `prefs` (so they are saved with them too).
fn store_tool_settings(prefs: &mut Prefs, t: &dyn Tool) {
let opts = t.options();
// The radius ↑ and ↓ set while drawing a rounded rectangle (or `tool.setOption`) is the
// Corner Radius preference from then on, as in the reference app
// (<https://helpx.adobe.com/illustrator/using/drawing-simple-lines-shapes.html>).
if t.id() == ROUNDED_RECTANGLE
&& let Some(r) = opts.get("cornerRadius").and_then(Value::as_f64).filter(|r| r.is_finite())
{
prefs.corner_radius = r.clamp(0.0, MAX_CORNER_RADIUS);
}
for (store, keys) in settings::stores(t.id()) {
let saved = prefs.tool_settings.entry(store.to_string()).or_default();
for k in keys {
@@ -399,6 +415,12 @@ impl Session {
self.tool.wants_ticks()
}
/// Does the active tool build a stroke from every position the pointer passed
/// ([`vectorcraft_tools::Tool::wants_samples`])?
pub fn tool_wants_samples(&self) -> bool {
self.tool.wants_samples()
}
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`])?
pub fn tool_waiting(&self) -> bool {
self.tool.waiting()
@@ -429,6 +451,21 @@ impl Session {
self.apply_actions(acts)
}
/// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering,
/// newline normalization or document formatting such as Typographer's Quotes)? An IME commit
/// the application rewrites is an application edit, not an accepted IME insertion.
pub fn tool_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text))
}
/// Does the active tool's open typing session still match the document? An IME commit after
/// an external edit starts a fresh session (an application-side reset), so its receipt must
/// not be accepted as an unchanged IME insertion.
pub fn tool_ime_session_matches(&mut self, view: ViewInfo) -> bool {
self.with_tool_cx(view, |t, cx| t.ime_session_matches_document(cx))
}
/// Whether the active tool currently owns keyboard text input, as the Type tool does while editing.
pub fn tool_wants_text(&self) -> bool {
self.tool.wants_text()
}
@@ -441,6 +478,37 @@ impl Session {
self.apply_actions(acts)
}
/// An IME replaces text around its composition before committing: delete `before`/`after`
/// units around the selection or pre-edit. The selection/pre-edit itself is preserved (the
/// following commit replaces it), and the commit shares the typing session, so the replacement
/// is one undo step.
pub fn tool_ime_delete_surrounding(
&mut self,
before: usize,
after: usize,
unit: vectorcraft_tools::SurroundingUnit,
view: ViewInfo,
) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.ime_delete_surrounding(cx, before, after, unit));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// The active tool's stable IME owner `(document uid, target id, replacement epoch)`: the
/// document uid changes on document replacement, the epoch when the same target is started
/// afresh. Ordinary edits and caret moves leave both unchanged.
pub fn tool_ime_target(&mut self, view: ViewInfo) -> Option<(u64, u64, u64)> {
self.with_tool_cx(view, |t, cx| t.ime_target(cx))
}
/// The committed logical text around the IME cursor (UTF-8 byte offsets), with any pre-edit
/// removed and at most `max_bytes` bytes copied: the app's outgoing surrounding-text producer
/// (egui E21b) reads it. An explicit [`ImeSurrounding::Unavailable`] carries the narrow reason
/// (no editor, invalid offsets, or a selection wider than the limit).
pub fn tool_ime_surrounding(&mut self, max_bytes: usize, view: ViewInfo) -> ImeSurrounding {
self.with_tool_cx(view, |t, cx| t.ime_surrounding(cx, max_bytes))
}
/// Is the active tool showing uncommitted IME text?
pub fn tool_composing(&self) -> bool {
self.tool.composing()
@@ -556,7 +624,19 @@ impl Session {
}
Self::store_tool_settings(&mut self.prefs, t);
let out = t.options();
if other.is_some() {
if let Some(other) = other {
// The tool that lent a selection tool for a Cmd-drag is kept aside, not made again when
// it is back: when it is the tool set, it takes the writes too (the Rounded Rectangle's
// radius stays the Corner Radius preference it just set) and what it shares, like the
// active tool.
if let Some(lender) = self.lender.as_deref_mut() {
if lender.id() == other.id() {
for (k, v) in values {
lender.set_option(k, v);
}
}
Self::restore_tool_settings(&self.prefs, lender);
}
Self::restore_tool_settings(&self.prefs, self.tool.as_mut());
}
out
+44
View File
@@ -346,3 +346,47 @@ fn a_freeform_gradient_expands_with_its_rgb_values_in_the_working_rgb_space() {
assert!(matches!(c, Color::Rgb { .. }) && close, "{c:?} shows {shown:?} for {field:?}");
}
}
/// Loading a new file of the working RGB profile (named after its description, as before) takes
/// effect at once: CMYK colors convert into the new file and the canvas redraws, without choosing
/// the working spaces again.
#[test]
fn loading_the_working_rgb_profile_again_takes_effect_at_once() {
let _g = settings();
let old = cms::active_settings();
// A built-in RGB profile's file renamed `name`: padded with spaces, which names drop.
let renamed = |builtin: &str, name: &str| {
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec();
let (from, to) = (utf16(builtin), utf16(&format!("{name:<width$}", width = builtin.len())));
let at = bytes.windows(from.len()).position(|w| w == from).unwrap();
bytes[at..at + to.len()].copy_from_slice(&to);
bytes
};
let name = "Reload RGB";
let dir = vectorcraft_testkit::temp_dir("reload-working-rgb");
let load = |s: &mut Session, builtin: &str| {
let path = dir.join(format!("{}.icc", builtin.len()));
std::fs::write(&path, renamed(builtin, name)).unwrap();
let r = s.execute("color.loadProfile", &json!({"path": path.to_str().unwrap()})).unwrap();
assert_eq!(r["name"], name);
};
let cmyk = cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some("Reload Working CMYK".into())).unwrap().name;
let cyan = [1.0, 0.0, 0.0, 0.0];
let convert = || cms::active().cmyk_to_rgb(cyan, old.intent);
let mut s = session();
load(&mut s, cms::DISPLAY_P3);
s.execute("edit.colorSettings", &json!({"rgb": name, "cmyk": cmyk})).unwrap();
let in_p3 = convert();
let revision = s.doc().unwrap().revision;
load(&mut s, cms::PROPHOTO_RGB);
let after = convert();
let redrawn = s.doc().unwrap().revision > revision;
// What a CMYK profile loaded afresh gives in the new file.
let fresh = cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some("Reload Working CMYK 2".into())).unwrap().name;
let want = cms::Cms::new(&cms::ColorSettings { cmyk: fresh, ..cms::active_settings() }).unwrap().cmyk_to_rgb(cyan, old.intent);
cms::set_active(&old).unwrap();
assert!(in_p3.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "Display P3 {in_p3:?} vs ProPhoto {want:?}");
assert!(after.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {after:?}, want {want:?}");
assert!(redrawn, "the canvas redraws");
}

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