diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b83d5a8f6..7b3b6bf5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0f91ed6d0..007fcfec0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fd996c43..d1677e538 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/ASSETS.md b/ASSETS.md index 8e096c1c3..186b87bb0 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -12,6 +12,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | | `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index d58dec2da..a208d60ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,66 +4,60 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -437,6 +432,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -449,6 +468,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -709,7 +734,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -718,7 +743,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -740,6 +770,39 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -795,6 +858,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -805,6 +874,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -818,7 +897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -831,7 +910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -871,12 +950,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "egui", "serde", @@ -886,7 +965,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "hayro-interpret", "hayro-syntax", @@ -897,29 +976,73 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree 0.20.0", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "wayland-client", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -929,6 +1052,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -991,6 +1126,36 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dify" version = "0.8.0" @@ -1074,10 +1239,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1085,9 +1255,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1110,6 +1279,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1121,9 +1291,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1141,9 +1310,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1161,30 +1329,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1197,9 +1368,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1211,9 +1381,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1238,13 +1407,26 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1253,22 +1435,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1294,9 +1476,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1312,16 +1493,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1401,6 +1583,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -1413,6 +1605,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1433,6 +1631,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1516,6 +1736,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1552,6 +1788,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1565,8 +1807,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1600,6 +1845,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1619,9 +1877,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1631,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.12", ] [[package]] @@ -1645,22 +1905,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1675,7 +1919,7 @@ dependencies = [ "png 0.18.1", "skrifa 0.44.0", "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1779,6 +2023,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1854,7 +2117,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "brotli", "hayro-postscript", @@ -1863,14 +2126,14 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "bitflags 2.13.2", "hayro-cmap", "hayro-syntax", "kurbo 0.13.1", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -1881,7 +2144,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -1899,12 +2162,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "flate2", "hayro-ccitt", @@ -1917,6 +2180,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1929,6 +2198,131 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2145,6 +2539,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-docker" version = "0.2.0" @@ -2469,18 +2869,18 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2632,6 +3032,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "md-5" version = "0.10.6" @@ -2666,6 +3072,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2686,6 +3098,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2698,9 +3121,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", @@ -2714,6 +3137,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -2782,6 +3211,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2797,6 +3238,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -3165,6 +3612,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3254,6 +3707,25 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3290,8 +3762,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3302,7 +3784,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3311,8 +3803,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3327,6 +3832,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -3382,6 +3896,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -3469,6 +3996,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3484,6 +4017,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3555,6 +4112,72 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3582,23 +4205,39 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ + "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3727,6 +4366,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -3777,6 +4459,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3839,6 +4535,83 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3890,6 +4663,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -3899,6 +4678,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3922,6 +4710,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3933,6 +4744,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -3977,6 +4798,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3997,6 +4829,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4024,6 +4879,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4212,6 +5236,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4254,6 +5288,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4266,6 +5321,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -4286,6 +5347,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4308,6 +5379,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -4319,6 +5399,38 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4400,10 +5512,40 @@ dependencies = [ "flate2", "half", "quick-error 2.0.1", - "weezl", + "weezl 0.1.12", "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4458,13 +5600,63 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -4518,6 +5710,51 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4561,6 +5798,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4585,6 +5828,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -4677,6 +5926,47 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4739,6 +6029,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4758,13 +6054,14 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.16.0" +version = "0.17.0" dependencies = [ "arboard", "block2 0.6.2", "chrono", "clipboard-win", "craft-print", + "craft-update", "craft-window", "eframe", "egui", @@ -4798,7 +6095,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.16.0" +version = "0.17.0" dependencies = [ "crc32fast", "flate2", @@ -4809,7 +6106,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -4821,7 +6118,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4837,7 +6134,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.16.0" +version = "0.17.0" dependencies = [ "log", "objc2-core-foundation", @@ -4859,7 +6156,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -4871,7 +6168,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.16.0" +version = "0.17.0" dependencies = [ "chrono", "hayro-ccitt", @@ -4890,7 +6187,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4906,7 +6203,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-docpath", "crc32fast", @@ -4950,7 +6247,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.16.0" +version = "0.17.0" dependencies = [ "fax", "flate2", @@ -4974,7 +6271,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.16.0" +version = "0.17.0" dependencies = [ "flate2", "proptest", @@ -4989,7 +6286,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -5001,7 +6298,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "log", @@ -5020,7 +6317,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -5034,7 +6331,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.16.0" +version = "0.17.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -5046,7 +6343,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.16.0" +version = "0.17.0" dependencies = [ "aes", "chrono", @@ -5072,12 +6369,12 @@ dependencies = [ "vectorcraft-geom", "vectorcraft-testkit", "vectorcraft-text", - "weezl", + "weezl 0.2.1", ] [[package]] name = "vectorcraft-plugins" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde", "serde_json", @@ -5091,13 +6388,14 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.16.0" +version = "0.17.0" dependencies = [ "flate2", "gif", "image", "jpeg-encoder", "log", + "png 0.18.1", "proptest", "psd", "serde_json", @@ -5110,22 +6408,23 @@ dependencies = [ "vectorcraft-plugins", "vectorcraft-testkit", "vectorcraft-text", - "vello_common 0.2.0", - "vello_cpu 0.2.0", + "vello_common", + "vello_cpu", "wat", - "weezl", + "weezl 0.2.1", + "winsafe", ] [[package]] name = "vectorcraft-scriptcheck" -version = "0.16.0" +version = "0.17.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.16.0" +version = "0.17.0" dependencies = [ "craft-docpath", "flate2", @@ -5150,7 +6449,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.16.0" +version = "0.17.0" dependencies = [ "fax", "image", @@ -5168,7 +6467,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.16.0" +version = "0.17.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -5182,14 +6481,14 @@ dependencies = [ "unicode-segmentation", "vectorcraft-doc", "vectorcraft-geom", - "vello_cpu 0.2.0", + "vello_cpu", "windows-registry", "write-fonts", ] [[package]] name = "vectorcraft-three-d" -version = "0.16.0" +version = "0.17.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -5197,7 +6496,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -5213,7 +6512,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.16.0" +version = "0.17.0" dependencies = [ "image", "kurbo 0.13.1", @@ -5225,11 +6524,12 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.16.0" +version = "0.17.0" dependencies = [ "bytemuck", "craft-keymap", "craft-ui", + "craft-update", "egui", "egui_extras", "egui_kittest", @@ -5262,7 +6562,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.16.0" +version = "0.17.0" dependencies = [ "eframe", "egui", @@ -5279,21 +6579,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -5310,18 +6595,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_cpu" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" -dependencies = [ - "bytemuck", - "glifo 0.2.0", - "hashbrown 0.17.1", - "vello_common 0.1.0", -] - [[package]] name = "vello_cpu" version = "0.2.0" @@ -5330,13 +6603,23 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo 0.3.0", + "glifo", "hashbrown 0.17.1", "ordered-channel", "png 0.18.1", "rayon", "thread_local", - "vello_common 0.2.0", + "vello_common", +] + +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", ] [[package]] @@ -5364,6 +6647,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5641,7 +6939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5679,9 +6977,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -5693,12 +6991,27 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "wgpu" version = "30.0.1" @@ -6106,8 +7419,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -6118,7 +7430,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -6164,6 +7476,37 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -6255,6 +7598,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -6280,6 +7658,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -6300,7 +7684,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.16.0" +version = "0.17.0" dependencies = [ "serde_json", ] @@ -6475,6 +7859,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -6510,6 +7914,23 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6522,6 +7943,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -6586,3 +8019,18 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index a7a8ae558..fc2d98997 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } @@ -112,10 +113,10 @@ zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] zune-core = "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" @@ -150,9 +151,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" } diff --git a/NOTICE b/NOTICE index 0d6f9dd09..c66f3efad 100644 --- a/NOTICE +++ b/NOTICE @@ -77,10 +77,10 @@ Shared PDF backend The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0 and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision -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. diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index 3827ab1eb..82f0b100d 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -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. diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 10a859ac5..6ec320ea2 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -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-.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 = 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 = None; let mut headless = false; let (mut read_roots, mut write_roots): (Vec, Vec) = (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 --automation-read-root --automation-write-root `" + 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 --automation-read-root --automation-write-root `" .into()); } let backend: Box = 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::, None::, 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::, None::, false, false, false, false); let mut decimals = None::; // 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::().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 { } 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 = None; let mut steps: Vec = 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(); diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index d760cfefe..8fe3d23b7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -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(); diff --git a/apps/vectorcraft-cli/tests/it/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs index e06444b39..c45c5d867 100644 --- a/apps/vectorcraft-cli/tests/it/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -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 ", + ); let mut child = Command::new(BIN) .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))]) diff --git a/apps/vectorcraft-cli/tests/it/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs index 104aeb0fe..1423b78b4 100644 --- a/apps/vectorcraft-cli/tests/it/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -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"); diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index 211bfdb18..8decb13d8 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -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"] } diff --git a/apps/vectorcraft-web/src/keys.rs b/apps/vectorcraft-web/src/keys.rs new file mode 100644 index 000000000..8cb27096b --- /dev/null +++ b/apps/vectorcraft-web/src/keys.rs @@ -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::() { + return true; + } + target + .dyn_ref::() + .and_then(|input| input.previous_element_sibling()) + .is_some_and(|before| before.has_type::()) +} + +/// 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::::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)); + } +} diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index f76e54786..f7dd201af 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -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))] diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs index 42f6294f2..b7140d7e5 100644 --- a/apps/vectorcraft-web/src/pencil.rs +++ b/apps/vectorcraft-web/src/pencil.rs @@ -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 +/// (). +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, /// The pen pressing, by pointer id. @@ -39,6 +46,16 @@ pub struct Pointers { /// The contact driving egui's pointer. primary: Option, button: Option, + /// 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) { 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(); } } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index c30799475..859d41ef7 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -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 { // 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) -> Services { +fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option, 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() } } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index fedc4acbe..561fd0e84 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -23,7 +23,7 @@ vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) # so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217. craft-window = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } +eframe = { workspace = true, features = ["clipboard", "default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -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. diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index e163ffb95..c7886df3e 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -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 + 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(); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fc3f0dea4..ee4232dc3 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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 { + app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook) +} +#[cfg(all(windows, not(target_vendor = "win7")))] +fn native_input_hook(_: &VectorcraftApp) -> Option { + Some(std::sync::Arc::new(windows_stylus::raw_input_hook)) +} +#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))] +fn native_input_hook(_: &VectorcraftApp) -> Option { + None +} + impl eframe::App for App { // Each frame runs unconfined: it is the person at the keyboard's. The app confines what the // control channel asks for, and the frames carrying input it injected, to the automation roots // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { file_access::unconfined(|| self.logic_frame(ctx, frame)); + self.app.finish_ime_logic(ctx); } - 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 { 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 { - use serde_json::{Map, Value, json}; + vectorcraft_ui_egui::UiState::read_preferences(bytes).map(|read| read.ui) +} - fn recover(object: &mut Map, hidden_key: &str) { - if let Some(layout) = object.get("docking") - && serde_json::from_value::(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::(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 `.json` or the first free `-N.json`, before the next +/// save replaces it: `ui.unreadable` when it could not be read in full (a newer VectorCraft may +/// have written it), `ui.before-layout-migration` when its layouts were converted. +fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str) { + use std::io::Write as _; + for n in 1..100u32 { + let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") }; + match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return; } + break; } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => break, } } - serde_json::from_value(value).ok() + KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); } fn load_prefs(app: &mut VectorcraftApp, saved: Option) { @@ -305,7 +332,7 @@ fn load_prefs(app: &mut VectorcraftApp, saved: Option 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::>()) { + 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] { diff --git a/apps/vectorcraft/src/system_fonts.rs b/apps/vectorcraft/src/system_fonts.rs index 6752aef67..67bbf7843 100644 --- a/apps/vectorcraft/src/system_fonts.rs +++ b/apps/vectorcraft/src/system_fonts.rs @@ -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 //! (, //! "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 { - 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 { } } -fn collection_files() -> windows::core::Result> { +fn listed_files() -> windows::core::Result> { // 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 = 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 { // 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 { - // 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 { + // 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 { } } +/// The strings of `list`. +fn strings(list: &IDWriteStringList) -> Vec { + // 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 { + // 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) -> windows::core::Result> { + // 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>> = 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( + 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` 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).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 { + // 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::>(); + 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()); + } } diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs index cf17976b5..814ba55fe 100644 --- a/apps/vectorcraft/src/windows_stylus.rs +++ b/apps/vectorcraft/src/windows_stylus.rs @@ -5,30 +5,74 @@ //! second-button flag ("analogous to a mouse right button down", //! ): [`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 +//! (). 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 +//! () +//! 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 = 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 = RefCell::new(BarrelStrokes::default()); } +/// The pen feed [`msg_hook`] writes, once [`start`] has made it. +static FEED: OnceLock = 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::().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, + from_mouse: impl FnOnce() -> bool, +) -> Option> { + 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 { 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(); diff --git a/apps/vectorcraft/tests/it/help.rs b/apps/vectorcraft/tests/it/help.rs index 7c7624031..059b9c476 100644 --- a/apps/vectorcraft/tests/it/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -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>| 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> { + 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)]) { diff --git a/contributors/people.toml b/contributors/people.toml index f8c651239..d43e6fa1b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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 diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 80d302b66..9ef049f86 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -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)); } } } diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 1f7af6e60..422f09d9c 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -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, + /// The width at the heaviest pen pressure (%), for Pressure (`width` without one). + #[serde(skip_serializing_if = "Option::is_none")] + pub width_max: Option, 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), }; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 8c04e102b..0968f5d6c 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -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, max: Option| { + 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() { diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 9d61216a1..d20fe5700 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -169,6 +169,33 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option f64) -> Option { + 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 { +/// 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 { 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 { +/// 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 { 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); } } diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 1ead944e2..8201b8b97 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -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>; -/// 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, @@ -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(); } diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index fa2449bc0..46e44cd76 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -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:?}"); +} diff --git a/crates/doc/src/id_index.rs b/crates/doc/src/id_index.rs index 582e4c25d..65a6962f1 100644 --- a/crates/doc/src/id_index.rs +++ b/crates/doc/src/id_index.rs @@ -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 = 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 = HashMap>; /// Where each node is: its parent's entry (`u32::MAX` for a top-level layer) and its index among diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a222e8e91..6349f9f4c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -360,10 +360,12 @@ fn parse_measure(s: &str, default: Unit) -> Option { }; } } + // Plain decimals only, as in `number_unit` (`str::parse` would accept `1e5`, `inf`, `nan`). + let plain = |t: &str| t.trim().chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') && t.trim().parse::().is_ok(); if let Some((p, pt)) = s.split_once('p') && !p.is_empty() - && p.trim().parse::().is_ok() - && (pt.is_empty() || pt.trim().parse::().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 { + 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, #[serde(default)] pub guides: Vec, - /// 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, /// 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, 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 = 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)] diff --git a/crates/doc/src/overprint.rs b/crates/doc/src/overprint.rs index a822728a3..056c8c888 100644 --- a/crates/doc/src/overprint.rs +++ b/crates/doc/src/overprint.rs @@ -142,8 +142,10 @@ impl Document { let ids: Vec = 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, &[]); } } diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index da310eb65..a0b19b844 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -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 { - 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) -> Vec { + 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 { - 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) { diff --git a/crates/doc/src/range.rs b/crates/doc/src/range.rs index eab847204..87db16f20 100644 --- a/crates/doc/src/range.rs +++ b/crates/doc/src/range.rs @@ -17,6 +17,7 @@ pub fn parse_range(s: &str, count: usize) -> Result, 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, 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); } } diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index d3860fc58..b5a1ffe32 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -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, diff --git a/crates/doc/src/slices.rs b/crates/doc/src/slices.rs index 8deba728f..33299a309 100644 --- a/crates/doc/src/slices.rs +++ b/crates/doc/src/slices.rs @@ -274,6 +274,10 @@ pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec { return vec![]; } let inside: Vec = 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 = 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 { // 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 { } 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; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 6ce257e78..067eae7b4 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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 { + 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>, 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()); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 5463857ed..9f0eb549d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -117,7 +117,7 @@ pub const PATHFINDER_MAX_OVERLAPS: usize = 16_384; struct Counts(Vec); // `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; diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs new file mode 100644 index 000000000..1c7de9978 --- /dev/null +++ b/crates/engine/src/cmd/brushlib.rs @@ -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 { + 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, + 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 { + 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 = 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 { + 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; + +/// 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> { + static MADE: LazyLock>> = LazyLock::new(|| BUILTIN.iter().map(|b| Arc::new((b.make)())).collect()); + BUILTIN.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned()) +} + +/// Library `name` of `brushes`, `doc` holding the resources their art uses. +fn built(name: &str, mut doc: Document, brushes: Vec) -> 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::group(NodeId(0), children.into_iter().map(Arc::new).collect()) +} + +fn polygon(pts: &[(f64, f64)]) -> PathData { + let pts: Vec = 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 = (0..=n).map(|i| len * i as f64 / n as f64).collect(); + let mut pts: Vec = 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 = (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, 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 = (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 { + 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)> { + 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)> { + 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 { + s.brush_libraries.rescan(); + let libs: Vec = libraries(s) + .into_iter() + .map(|l| { + let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)}) + }) + .collect(); + Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let (info, lib) = library_param(s, p, "brush.library")?; + let brushes: Vec = 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 { + 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 = 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 = 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 = 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 = 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 { + 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 { + 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()})) +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index d27e70f51..d485366ea 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "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 { "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 { "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> { +pub(crate) fn roots(s: &Session, p: &Value) -> Result> { 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 { +pub(crate) fn brush_targets(d: &Document, ids: &[NodeId]) -> Vec { let mut out = vec![]; for id in ids { let Some(n) = d.node(*id) else { continue }; @@ -302,6 +302,27 @@ fn brush_selection_art(s: &mut Session, p: &Value) -> Result { Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) } +/// The stroke paint a brush gives paths without one: the current stroke colour, else black. +pub(crate) fn stroke_paint(s: &Session) -> Paint { + if s.paint.stroke.is_none() { Paint::solid(Color::BLACK) } else { s.paint.stroke.clone() } +} + +/// Paint paths `ids` of `d` with brush `name` (a path without a stroke takes `stroke`). +pub(crate) fn apply_brush(d: &mut Document, ids: &[NodeId], name: &str, stroke: &Paint) { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) { + n.appearance.set_stroke(stroke.clone()); + } + if let Some(st) = n.appearance.stroke_mut() { + if st.width <= 0.0 { + st.width = 1.0; + } + st.brush = Some(name.to_string()); + } + } +} + fn brush_apply(s: &mut Session, p: &Value) -> Result { const C: &str = "brush.apply"; let name = name_param(p, C)?.to_string(); @@ -309,21 +330,10 @@ fn brush_apply(s: &mut Session, p: &Value) -> Result { 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 { 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 { 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 { 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 { } } 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) })?; diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 3f7da1629..03166a5b0 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -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 { "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 { "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 { 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 = leaves.into_iter().cloned().collect(); if map.shapes.is_empty() { @@ -327,7 +339,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { return Ok((ids, None, touched, Some(split))); } let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; - 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 { 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 { .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 { 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> { + let groups = &s.doc()?.doc.swatch_groups; + let lower = name.to_lowercase(); + let group = groups + .iter() + .find(|g| g.name == name) + .or_else(|| groups.iter().find(|g| g.name.to_lowercase() == lower)) + .ok_or_else(|| bad(c, format!("no color group `{name}` in the document (see swatch.list)")))?; + Ok(group.swatches.iter().filter_map(|w| w.paint.color()).collect()) +} + +/// Document Library in Color mode: look the library's colors (or its color group's) up into +/// `params.swatches` → each one as traced (sRGB) with the color to fill with. Errors name command `c`. fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result> { if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { return Ok(vec![]); } - let 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"))); } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index d51ef6907..a01f095fc 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -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; diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index ac01a8129..e6bd96371 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -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>), + 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>) -> 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) -> Self { let mut used = Used::default(); - let mut todo: Vec> = nodes.iter().map(Source::Node).collect(); + let mut todo: Vec> = 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>, Vec>>), +} + +/// 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) -> 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>, a: &Arc) { + (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], theirs: &[Arc]) -> 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) -> 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::>(); - 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 { + 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 diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 91afee24c..40144f13e 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -195,6 +195,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { 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})) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index dd841c661..8c4e71f1f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "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)), diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index 7b18289e9..faf905052 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -209,8 +209,8 @@ fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option= 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> { 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 pieces.into_iter().flat_map(|p| p.subpaths).collect(), None => pd.subpaths.clone(), }; diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 4446bc0ad..0dc6fbad3 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "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 { } 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 { } 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 { 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(()) diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index ca5ef0909..0ef78a06a 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -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 { "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 { "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 { 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> { +/// The pieces of `sp` where `remove(point)` is false. `None` when nothing is removed. Each segment +/// is sampled at least every `step` pt (and at least 24 times), so a removed stretch at least +/// `step` long is found. +fn cut_subpath(sp: &SubPath, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { let nseg = sp.segment_count(); if nseg == 0 { return sp.anchors.first().filter(|a| remove(a.p)).map(|_| vec![]); } const K: usize = 24; + const MAX_SAMPLES: usize = 4096; let mut runs: Vec> = vec![]; let mut cur: Vec<(usize, f64, f64)> = vec![]; let mut any = false; for i in 0..nseg { let c = sp.segment(i); + // The curve moves at most 3 times its longest control-polygon edge per unit of t, so with + // n samples, neighboring samples are at most 3 · edge / n apart. + let edge = (c.p1 - c.p0).hypot().max((c.p2 - c.p1).hypot()).max((c.p3 - c.p2).hypot()); + let wanted = (3.0 * edge / step).ceil(); + let samples = if wanted.is_finite() && wanted > K as f64 { (wanted as usize).min(MAX_SAMPLES) } else { K }; let mut prev_t = 0.0; let mut prev_rm = remove(c.eval(0.0)); let mut start = if prev_rm { None } else { Some(0.0) }; @@ -431,8 +440,8 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option bool) -> Option bool) -> Option> { +/// Erase from an outline path (open or closed) where `remove` holds, sampling each segment at least +/// every `step` pt (`f64::INFINITY` for the fixed sampling). Returns the new geometry per output +/// object (one object per piece when the path had a single subpath), or None if unchanged. +pub(super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(Point) -> bool) -> Option> { let mut changed = false; let mut subs = vec![]; for sp in &pd.subpaths { - match cut_subpath(sp, 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 = 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, 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, 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, Vec) = if sp.closed { @@ -664,7 +684,9 @@ fn freehand(s: &mut Session, p: &Value) -> Result { } let pressure = stroke_pressure(&samples); let mut pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); - let tol = number_or(p, "fidelity", 1.5, C)?.clamp(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 { 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 { 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 { 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 { 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 { 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 { 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 = pts.iter().map(|q| squash * *q).collect(); let mut plan: Vec<(NodeId, Vec)> = vec![]; for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { let (pd, rule) = path_of(&st.doc, id)?; @@ -1158,7 +1261,7 @@ fn erase_region(s: &mut Session, p: &Value) -> Result { continue; } plan.push((id, components(&po::boolean(&pd, rule, ®ion, 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 { 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 { 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 { .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 { 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 { } let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, 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); }; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index aadd456ef..fa4878d2c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { ), 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))), diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 7e757eb19..90856aaa4 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -56,7 +56,13 @@ pub(super) fn action_batch(s: &mut Session, p: &Value) -> Result { 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"); diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index cf760ddda..ff8b8aece 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -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 { 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 { /// `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, cmds: &mut [Value]) -> Result> { +/// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them. +fn run_steps( + s: &mut Session, + label: &str, + artboard: Option, + cmds: &mut [Value], + grouped: &mut Vec<(Vec, Option)>, +) -> Result> { 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, 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)> = 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, ¶ms).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, 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, held: &[(u64, Arc)]) -> (Vec, Option) { + 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 diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 5a5489863..d33037715 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -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 { "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), _.eps, and with two or more a central .eps of the whole document; false or absent: one file of the visible art (naming artboards also uses them)", + description: "true: one file per chosen artboard (default all), _.eps, and with two or more a central .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, include_linked: bool) -> Vec } /// 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 { let set = parse(p)?; let original = doc; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 3c3600dd1..9f350cb4d 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -338,7 +338,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result 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 }) } @@ -518,8 +518,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 { use image::ImageDecoder as _; if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -533,6 +533,17 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option { (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 { + 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`]); diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 31005dc27..339b43d96 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -33,7 +33,7 @@ pub(crate) mod imagemap; pub mod jxlread; 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; @@ -75,7 +75,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, }; @@ -130,7 +130,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, 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., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 3eb64f3ea..486777a24 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -185,6 +185,14 @@ fn parent_folder(path: &str) -> Option { 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 { + 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 { let s = s.trim().to_ascii_lowercase(); - let num = |t: &str| t.trim().parse::().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::().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 { 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 { 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)?; + // ``: 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 { 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) -> R pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { 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"); + } +} diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 530b681ba..2cbfdd9b7 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -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))); } diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 2ab0aae0c..af918d33e 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -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] diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 867911681..5634c0da7 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -205,6 +205,39 @@ fn the_first_file_is_the_file_the_export_writes_first() { } } +/// A saved document's ``, 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": "__", "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: `` 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": "", "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() { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 05ec602e6..7b56c2f46 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -138,6 +138,15 @@ pub fn specs() -> Vec { 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 { fn artboard_delete(s: &mut Session, p: &Value) -> Result { 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 { + let mut empty: Vec = 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 { + 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 diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index ef5be1322..aef05cc8a 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "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 { // 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) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index c9ff3f455..904e75b4e 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -94,7 +94,7 @@ pub fn specs() -> Vec { "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 { 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) })?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9c5d38158..411f2004a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 22ef28d13..c4cf27198 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1804,7 +1804,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { } fn set_bounds(s: &mut Session, p: &Value) -> Result { - 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), } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index a874bc88f..91579a1b9 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -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, 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" ); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index e7c963d02..f6e555892 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "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 { 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 { "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 { "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 { 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 Result Result>().len()})) } /// Does `p` ask for `span: "selection"` (#2127)? Only a linear or radial gradient the targets diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index b2a2aaec9..5b814b10b 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -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 { 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 { })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } + +/// 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, + list: Option, + marker: Option, + 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, + 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, + 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::(), 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::(); + 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::() + }; + 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 { + 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![]; + 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 = vec![]; + let mut named_parts = vec![]; + let plains: Vec = 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> = 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))) = (¶.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 = edits.iter().map(|(id, _, _)| id.0).collect(); + let inserted: Vec = 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 })) +} diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 1c8ba5ffc..875721b92 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -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 diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index f55378a34..4de797583 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -467,6 +467,9 @@ fn join(s: &mut Session, p: &Value) -> Result { 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 { // 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(); } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index f898ff40e..8f3e0f117 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -23,90 +23,90 @@ pub fn specs() -> Vec { "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 { 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 = 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 { + 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 { + 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 { +/// 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 { + 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 = + 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 { 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 { 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