diff --git a/ASSETS.md b/ASSETS.md index aa3bfeda0..e4a17c764 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -358,20 +358,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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/f8f8829cc7be65d466d687dae381d1ca60e31242/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/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 137e9a2be..ae8a3ee6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,17 +970,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "egui", "serde", @@ -1013,7 +1013,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "hayro-interpret", "hayro-syntax", @@ -1024,7 +1024,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "craft-winprint-gdi", "log", @@ -1038,7 +1038,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1085,7 +1085,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "flate2", "serde", @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "egui", "objc2 0.6.5", @@ -2206,7 +2206,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "brotli", "hayro-postscript", @@ -2215,7 +2215,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2233,7 +2233,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2251,12 +2251,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "flate2", "hayro-ccitt", @@ -2864,7 +2864,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242" +source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" dependencies = [ "base64 0.22.1", "bumpalo", @@ -6284,6 +6284,7 @@ dependencies = [ "vectorcraft-effects", "vectorcraft-geom", "vectorcraft-metafile", + "vectorcraft-pathops", "vectorcraft-pdf", "vectorcraft-render", "vectorcraft-testkit", diff --git a/Cargo.toml b/Cargo.toml index 1af59ddfe..924a1a68d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,16 +11,16 @@ rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", default-features = false, features = ["interpret", "write"] } -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["serde"] } -craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] } +craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # 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 = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal @@ -147,12 +147,12 @@ lto = "thin" codegen-units = 1 [patch.crates-io] -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } -hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } -krilla = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } 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" } diff --git a/NOTICE b/NOTICE index 002fa016a..4bf18085b 100644 --- a/NOTICE +++ b/NOTICE @@ -72,10 +72,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 -f8f8829cc7be65d466d687dae381d1ca60e31242. Their upstream project is +9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb. 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/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/README.craft-reader.md. +https://github.com/storytold/craft-libs/blob/9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb/vendor/README.craft-reader.md. Assets already bundled through Hayro's default features retain their licences: - Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved. diff --git a/ROADMAP.md b/ROADMAP.md index 6cf80d362..16bd8b1da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). diff --git a/apps/vectorcraft-web/src/keys.rs b/apps/vectorcraft-web/src/keys.rs index 8cb27096b..e2f3ea0b4 100644 --- a/apps/vectorcraft-web/src/keys.rs +++ b/apps/vectorcraft-web/src/keys.rs @@ -27,6 +27,19 @@ pub(crate) fn kept_from_browser(key: &str, code: &str, modifiers: Modifiers, alt vectorcraft_ui_egui::shortcuts::runs_a_shortcut(key, code, modifiers) } +/// Leave a paste to its Paste event (#5555). eframe's web runner reports a Cmd+V press both as the +/// key press and, with the clipboard's text, as a Paste event (the browser's `paste` comes in the +/// same task as the `keydown`, so both are in one frame's input); the app pastes on each, as egui's +/// window integration sends only the Paste. With a Paste in `events`, the paste key's press is +/// dropped; its release stays, so the paste chord is still followed (`PasteChord`). A Cmd+V without +/// a Paste (a clipboard with no text) keeps its press. +pub(crate) fn drop_paste_key_presses(events: &mut Vec) { + if !events.iter().any(|e| matches!(e, egui::Event::Paste(_))) { + return; + } + events.retain(|e| !matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command)); +} + /// 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 { @@ -115,4 +128,31 @@ mod tests { assert!(!kept_from_browser("ż", "KeyZ", ctrl_alt, true)); assert!(!kept_from_browser("}", "Digit0", ctrl_alt, true)); } + + fn key(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } + } + + #[test] + fn a_paste_chord_pastes_once() { + // Cmd+V and Cmd+Shift+V (Paste in Place): the press goes, the Paste and the release stay. + for mods in [cmd(), cmd() | Modifiers::SHIFT] { + let mut events = vec![key(Key::V, true, mods), egui::Event::Paste("".into()), key(Key::V, false, mods)]; + drop_paste_key_presses(&mut events); + assert_eq!(events, vec![egui::Event::Paste("".into()), key(Key::V, false, mods)], "{mods:?}"); + } + } + + #[test] + fn keys_without_a_paste_stay() { + // No text on the clipboard: no Paste event, so the press is the paste chord's only report. + let mut events = vec![key(Key::V, true, cmd()), key(Key::V, false, cmd())]; + drop_paste_key_presses(&mut events); + assert_eq!(events.len(), 2); + // Typing V, and other chords, beside a paste. + let typed = vec![key(Key::V, true, Modifiers::NONE), key(Key::C, true, cmd()), egui::Event::Paste("a".into())]; + let mut events = typed.clone(); + drop_paste_key_presses(&mut events); + assert_eq!(events, typed); + } } diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 859d41ef7..75eb7b308 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -378,6 +378,8 @@ impl eframe::App for WebShell { /// 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()); + // A Cmd+V comes as its key and as a Paste: one paste (#5555). + crate::keys::drop_paste_key_presses(&mut raw.events); if let Some(app) = &mut self.app { app.raw_input_hook(ctx, raw); } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 47d26eb7b..f46213231 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -56,8 +56,9 @@ pollster = "1" # `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down. # Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the # cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which -# src/caps_lock.rs uses to read whether Caps Lock is on. -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } +# src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`, +# which src/host_services.rs uses to read the path PhotoCraft registers under App Paths. +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] } # The renderer's worker count follows those cores (src/windows_cores.rs). vectorcraft-render = { workspace = true } diff --git a/apps/vectorcraft/src/control_server/tests.rs b/apps/vectorcraft/src/control_server/tests.rs index e3d946ef0..d991fd602 100644 --- a/apps/vectorcraft/src/control_server/tests.rs +++ b/apps/vectorcraft/src/control_server/tests.rs @@ -58,7 +58,12 @@ fn idle_control_socket_releases_its_worker() { fn idle_expiry_closes_without_writing_a_line() { let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected")); let mut got = Vec::new(); - client.read_to_end(&mut got).unwrap(); + match client.read_to_end(&mut got) { + // On Windows the server's close can reach the client as a reset. + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {} + Ok(_) => {} + Err(e) => panic!("reading the idle connection: {e}"), + } assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection"); expect_socket_deadline(client, done, worker); } @@ -334,7 +339,9 @@ fn tcp_connection_is_closed_after_junk() { }); let mut c = TcpStream::connect(addr).unwrap(); c.set_read_timeout(Some(WAIT)).unwrap(); - c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap(); + // Nothing after the junk line: on Windows, closing a socket with unread input resets the + // connection, and the reset can discard the reply before the client reads it. + c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n").unwrap(); let mut text = String::new(); c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection server.join().unwrap(); diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 00f92405e..d7d917ed4 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -753,13 +753,22 @@ enum FirstFrame { Shown, /// Overdue. Hung, + /// Overdue, but the UI is still building the first frame: the app's own work (on a slow + /// machine, or with antivirus reading every font file), not the adapter, which has been given + /// nothing to show yet. + Busy, } -/// The window's start after `frames` frames run, `since` its first frame was due. -fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame { +/// The window's start after `frames` frames run, `since` its first frame was due and +/// `since_built` the first frame's UI pass ended (`None` while it runs). A frame counts once its +/// UI pass ends, before it is painted, so the adapter's time starts there: with no frame run, the +/// UI thread is in the app's code, and starting again without the adapter wouldn't help (#5160). +fn first_frame(frames: u64, since: Duration, since_built: Option, minimized: bool) -> FirstFrame { if frames >= 2 || minimized { FirstFrame::Shown - } else if since >= FIRST_FRAME_TIMEOUT { + } else if frames == 0 { + if since >= FIRST_FRAME_TIMEOUT { FirstFrame::Busy } else { FirstFrame::Wait } + } else if since_built.unwrap_or(since) >= FIRST_FRAME_TIMEOUT { FirstFrame::Hung } else { FirstFrame::Wait @@ -767,18 +776,34 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame { } /// Watch the window's first frame from another thread: once it reaches the screen, clear the start -/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was -/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for -/// one that failed, and end this process, whose UI thread is stuck in the graphics driver. +/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after its UI pass +/// ended, start the app again without the adapter, as [`finish`] does for one that failed, and end +/// this process, whose UI thread is stuck in the graphics driver. A first frame still being built +/// that long is waited for, with a note in the log. pub fn watch_first_frame(startup: Arc) { + let mut built: Option = None; + let mut noted = false; let watch = move || loop { std::thread::sleep(Duration::from_millis(250)); let Some(due) = startup.ready.get() else { continue }; let ui = startup.ui.get(); let frames = ui.map_or(0, egui::Context::cumulative_frame_nr); + if frames >= 1 && built.is_none() { + built = Some(Instant::now()); + } let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false); - match first_frame(frames, due.elapsed(), minimized) { + match first_frame(frames, due.elapsed(), built.map(|t| t.elapsed()), minimized) { FirstFrame::Wait => continue, + FirstFrame::Busy => { + if !noted { + log::warn!( + "the window's first frame is still being built after {}s: waiting for it (the app's work, not the graphics adapter)", + FIRST_FRAME_TIMEOUT.as_secs() + ); + noted = true; + } + continue; + } FirstFrame::Shown => { startup.presented(); return; diff --git a/apps/vectorcraft/src/gpu/tests.rs b/apps/vectorcraft/src/gpu/tests.rs index b1511a232..40d84934f 100644 --- a/apps/vectorcraft/src/gpu/tests.rs +++ b/apps/vectorcraft/src/gpu/tests.rs @@ -366,14 +366,19 @@ fn gpus_without_pci_ids_are_told_apart_by_name() { fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() { let s = Duration::from_secs; // The second frame began: the first was presented. - assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown); - // Still drawing (or presenting) the first frame: waited for, then given up on (#964). - for frames in [0, 1] { - assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait); - assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung); - } + assert_eq!(first_frame(2, s(60), Some(s(1)), false), FirstFrame::Shown); + // The first frame built and not yet presented: waited for, then given up on (#964), counting + // from when its UI pass ended. + assert_eq!(first_frame(1, s(3), Some(s(1)), false), FirstFrame::Wait); + assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, Some(FIRST_FRAME_TIMEOUT), false), FirstFrame::Hung); + assert_eq!(first_frame(1, s(45), Some(s(5)), false), FirstFrame::Wait, "a first frame built slowly gets its full time to show"); + assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Hung); + // The first frame still being built: the app's work, not the adapter's (#5160). + assert_eq!(first_frame(0, s(3), None, false), FirstFrame::Wait); + assert_eq!(first_frame(0, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Busy); + assert_eq!(first_frame(0, s(600), None, false), FirstFrame::Busy, "never a restart without an adapter"); // A window minimized at the start may not draw again until restored. - assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown); + assert_eq!(first_frame(1, s(60), Some(s(60)), true), FirstFrame::Shown); // A hung adapter is left out once, like one that failed. let skip = vec!["Dx12:10de:28a0".to_string()]; assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 16fe1d469..2ca282d74 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -44,19 +44,50 @@ macro_rules! dialog_settings { dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); +/// On Linux and the BSDs the dialog is GTK's or the desktop portal's, which match a file type's +/// `*.ext` patterns case-sensitively. +const CASE_SENSITIVE_FILTERS: bool = + cfg!(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")); + +/// The extensions an open dialog's filter is given for `exts`: with `any_case`, each extension +/// that has a letter is followed by a pattern with each letter as a `[xX]` pair, so the filter for +/// `dib` also lists `image.DIB` and `image.Dib`. +fn open_extensions(exts: &[impl AsRef], any_case: bool) -> Vec { + let mut out = Vec::with_capacity(exts.len() * 2); + for e in exts { + let e = e.as_ref(); + out.push(e.to_string()); + if any_case && e.chars().any(|c| c.is_ascii_alphabetic()) { + out.push( + e.chars() + .map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() }) + .collect(), + ); + } + } + out +} + /// `d` set up for `request`: its title, file types, folder and suggested name. fn dialog_for(d: D, request: &PickRequest) -> D { let pick = match request { PickRequest::Open(pick) | PickRequest::Save(pick) => pick, PickRequest::OpenMany => { return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) + d.filter( + vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), + &open_extensions(exts, CASE_SENSITIVE_FILTERS), + ) }); } PickRequest::Folder => return d, }; + let any_case = CASE_SENSITIVE_FILTERS && matches!(request, PickRequest::Open(_)); let d = pick.filters.iter().fold(d, |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) + d.filter( + vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), + &open_extensions(exts, any_case), + ) }); let d = match &pick.folder { Some(folder) => d.folder(folder), @@ -461,24 +492,13 @@ pub(crate) fn windows_candidates( } /// The paths PhotoCraft's installer registers under App Paths, for the current user and then for -/// everyone (`reg query`, so no extra dependency; a hidden console window). +/// everyone. #[cfg(windows)] fn registered_photocraft() -> Vec { - use std::os::windows::process::CommandExt as _; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - // reg.exe by its full path, so nothing named `reg` in the app's folder or on PATH runs instead. - let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into()); - ["HKCU", "HKLM"] + use windows::Win32::System::Registry::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE] .into_iter() - .filter_map(|hive| { - let key = format!(r"{hive}\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe"); - let out = std::process::Command::new(root.join("System32").join("reg.exe")) - .args(["query", &key, "/ve"]) - .creation_flags(CREATE_NO_WINDOW) - .output() - .ok()?; - registered_path(&String::from_utf8_lossy(&out.stdout)) - }) + .filter_map(|hive| registry_default_path(hive, r"Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe")) .collect() } @@ -487,11 +507,35 @@ fn registered_photocraft() -> Vec { Vec::new() } -/// The path in `reg query … /ve` output like -/// ` (Default) REG_SZ C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`. +/// The default `REG_SZ` value of `subkey` under `hive` as a path ([`registered_path`]), or `None` +/// when the key or the value is missing. The value is read as UTF-16, so a folder name in any +/// script comes back as stored. +#[cfg(windows)] +#[allow(unsafe_code)] +fn registry_default_path(hive: windows::Win32::System::Registry::HKEY, subkey: &str) -> Option { + use windows::Win32::System::Registry::{RRF_RT_REG_SZ, RegGetValueW}; + use windows::core::PCWSTR; + let subkey: Vec = subkey.encode_utf16().chain([0]).collect(); + let mut size = 0u32; + // SAFETY: `subkey` is a NUL-terminated UTF-16 string that outlives the call, a null value name + // reads the default value, and with no buffer the call only writes the value's size to `size`. + unsafe { RegGetValueW(hive, PCWSTR(subkey.as_ptr()), PCWSTR::null(), RRF_RT_REG_SZ, None, None, Some(&mut size)) }.ok().ok()?; + let mut value = vec![0u16; (size as usize).div_ceil(2)]; + let mut size = u32::try_from(value.len() * 2).ok()?; + // SAFETY: as above, and `value` holds the `size` bytes the call may write. + unsafe { RegGetValueW(hive, PCWSTR(subkey.as_ptr()), PCWSTR::null(), RRF_RT_REG_SZ, None, Some(value.as_mut_ptr().cast()), Some(&mut size)) } + .ok() + .ok()?; + registered_path(&value) +} + +/// The path in a registry string value read as UTF-16, up to its first NUL, without surrounding +/// spaces or quotes, like `C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`. #[cfg_attr(not(any(windows, test)), allow(dead_code))] -pub(crate) fn registered_path(text: &str) -> Option { - text.lines().find_map(|l| l.split_once("REG_SZ")).map(|(_, v)| v.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from) +pub(crate) fn registered_path(value: &[u16]) -> Option { + let end = value.iter().position(|&c| c == 0).unwrap_or(value.len()); + let text = String::from_utf16(&value[..end]).ok()?; + Some(text.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from) } /// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on @@ -616,17 +660,118 @@ mod open_with_tests { ); } - /// The registered path is read from `reg query … /ve` output, spaces and quotes included; - /// no value, no path. + /// The registered path is read from the UTF-16 value up to its NUL, spaces, quotes and + /// characters outside ASCII included; an empty value or one that is not UTF-16 gives no path. #[test] - fn the_registered_path_is_read_from_reg_query() { - let out = "\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\ArtCraft\\PhotoCraft\\photocraft.exe\r\n"; - assert_eq!(registered_path(out), Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe"))); + fn the_registered_path_is_read_from_the_utf16_value() { + let wide = |s: &str| s.encode_utf16().chain([0, 0]).collect::>(); assert_eq!( - registered_path(" (Standard) REG_SZ \"C:\\A B\\photocraft.exe\""), - Some(std::path::PathBuf::from(r"C:\A B\photocraft.exe")) + registered_path(&wide(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe")), + Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe")) ); - assert_eq!(registered_path("ERROR: The system was unable to find the specified registry key or value."), None); - assert_eq!(registered_path(" (Default) REG_SZ "), None); + assert_eq!( + registered_path(&wide(" \"C:\\Prögram Fïles\\Тест\\写真\\photocraft.exe\" ")), + Some(std::path::PathBuf::from(r"C:\Prögram Fïles\Тест\写真\photocraft.exe")) + ); + assert_eq!(registered_path(&wide("")), None); + assert_eq!(registered_path(&[0xD800, 0]), None); + } + + /// On Windows a registered path with characters outside the console's code page is read as + /// stored, from a scratch key under the current user's hive. + #[cfg(windows)] + #[test] + fn a_registered_path_outside_ascii_is_read_as_stored() { + let key = format!(r"Software\VectorCraftTest-app-paths-{}", std::process::id()); + let path = r"C:\Prögram Fïles\Тест\写真\photocraft.exe"; + let reg = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into()).join(r"System32\reg.exe"); + let full = format!(r"HKCU\{key}"); + let added = std::process::Command::new(®).args(["add", &full, "/ve", "/t", "REG_SZ", "/d", path, "/f"]).output().unwrap(); + assert!(added.status.success(), "{added:?}"); + let read = super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key); + let _ = std::process::Command::new(®).args(["delete", &full, "/f"]).output(); + assert_eq!(read, Some(std::path::PathBuf::from(path))); + assert_eq!(super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key), None, "deleted"); + } +} + +#[cfg(test)] +mod dialog_tests { + use super::{CASE_SENSITIVE_FILTERS, DialogSettings, FilePick, PickRequest, dialog_for, fileio, open_extensions}; + + /// The file types a dialog was given, by label. + #[derive(Default)] + struct Filters(Vec<(String, Vec)>); + + impl DialogSettings for Filters { + fn parent(self, _window: &winit::window::Window) -> Self { + self + } + fn title(self, _title: &str) -> Self { + self + } + fn filter(mut self, name: &str, exts: &[impl ToString]) -> Self { + self.0.push((name.to_string(), exts.iter().map(ToString::to_string).collect())); + self + } + fn folder(self, _folder: &str) -> Self { + self + } + fn file_name(self, _name: &str) -> Self { + self + } + } + + /// Does the case-sensitive pattern `*.{ext}` match `name`? `ext` may hold literal characters, + /// `*` and `[..]` sets. + fn matches(ext: &str, name: &str) -> bool { + fn here(p: &[char], n: &[char]) -> bool { + match p.split_first() { + None => n.is_empty(), + Some(('*', rest)) => (0..=n.len()).any(|i| here(rest, &n[i..])), + Some(('[', rest)) => { + let Some(end) = rest.iter().position(|&c| c == ']') else { return false }; + n.split_first().is_some_and(|(c, n)| rest[..end].contains(c) && here(&rest[end + 1..], n)) + } + Some((c, rest)) => n.split_first().is_some_and(|(d, n)| d == c && here(rest, n)), + } + } + let pattern: Vec = format!("*.{ext}").chars().collect(); + here(&pattern, &name.chars().collect::>()) + } + + /// Where filters match case-sensitively, every extension of File › Open and Place lists files + /// whose extension is in capitals or mixed case, `image.DIB` and `image.Pns` included. + #[test] + fn open_and_place_filters_list_extensions_in_any_case() { + let all = fileio::open_filters().chain(fileio::place_filters()); + for (label, exts) in all { + let patterns = open_extensions(exts, true); + for ext in exts { + let mut mixed = ext.to_ascii_uppercase(); + mixed.replace_range(..1, &ext[..1]); + for name in [format!("a.{ext}"), format!("a.{}", ext.to_ascii_uppercase()), format!("a.{mixed}")] { + assert!(patterns.iter().any(|p| matches(p, &name)), "{label}: {patterns:?} don't match {name}"); + } + } + } + assert_eq!(open_extensions(&["dib", "jp2", "*"], true), ["dib", "[dD][iI][bB]", "jp2", "[jJ][pP]2", "*"]); + assert_eq!(open_extensions(&["dib", "PNS"], false), ["dib", "PNS"]); + } + + /// Open and Place dialogs get the any-case patterns on Linux and the BSDs; a save dialog's file + /// types stay as listed. + #[test] + fn open_dialogs_get_any_case_patterns_where_filters_are_case_sensitive() { + let dib = vec![FilePick::filter("BMP", &["bmp", "dib"])]; + let pick = FilePick { filters: dib.clone(), ..Default::default() }; + let expected = |any_case: bool| open_extensions(&["bmp", "dib"], any_case); + let opened = dialog_for(Filters::default(), &PickRequest::Open(pick.clone())).0; + assert_eq!(opened[0].1, expected(CASE_SENSITIVE_FILTERS)); + let saved = dialog_for(Filters::default(), &PickRequest::Save(pick)).0; + assert_eq!(saved, dib); + let placed = dialog_for(Filters::default(), &PickRequest::OpenMany).0; + let first = fileio::place_filters().next().map(|(_, exts)| open_extensions(exts, CASE_SENSITIVE_FILTERS)); + assert_eq!(placed.first().map(|(_, exts)| exts.clone()), first); } } diff --git a/apps/vectorcraft/src/single_instance.rs b/apps/vectorcraft/src/single_instance.rs index b8df2bb5a..f7026131c 100644 --- a/apps/vectorcraft/src/single_instance.rs +++ b/apps/vectorcraft/src/single_instance.rs @@ -329,10 +329,15 @@ mod tests { #[test] fn a_stale_endpoint_file_is_ignored_and_replaced() { let (path, dir, file) = folder("stale"); - // No listener on the recorded port any more. - let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); + // The recorded app is gone: whatever holds the port closes without a greeting. (A freed + // port can be taken by another test's listener meanwhile, which would then accept this + // connection in place of its own client's.) + let gone = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = gone.local_addr().unwrap().port(); write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap(); + let closed = std::thread::spawn(move || drop(gone.accept())); assert!(forward(&path, std::slice::from_ref(&file)).is_err()); + closed.join().unwrap(); // A listener that doesn't know the greeting gets neither the token nor the paths. let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); let port = impostor.local_addr().unwrap().port(); diff --git a/contributors/people.toml b/contributors/people.toml index db94e2a89..405c471a2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -193,3 +193,19 @@ sync_github_name = true [people.JonRC] real_name = "Jonathan Cardoso" display_name = "Jonathan Cardoso" + +[people.vangel76] +real_name = "Valentin Klein" +display_name = "vangel76" +sync_github_name = false + +[people.FrancoJacquet] +real_name = "Franco Jacquet" + +[people.FractalEncrypt] +sync_github_name = true + +[people.sadkotheguest] +real_name = "Alexander Buraga" +display_name = "Alexander Buraga" +sync_github_name = false diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 7d8799186..6c6524617 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -236,7 +236,8 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec { return out; } let mut els = vec![]; - kurbo::flatten(bp.iter(), tol.max(1e-4), |el| { + // Guarded: kurbo works out every subdivision of a curve even when the callback drops them. + vectorcraft_geom::flatten(bp.iter(), tol.max(1e-4), |el| { if els.len() < MAX_FLATTENED { els.push(el); } diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index ae0c170f3..c52f7f630 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -103,7 +103,7 @@ fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option> { let mut bp = BezPath::new(); sp.to_bezpath_into(&mut bp); let mut pts = vec![]; - kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| { + vectorcraft_geom::flatten(bp.iter(), flat_tol.max(1e-4), |e| { if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e { pts.push(q); } diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index d7e20654a..72fa4f7b3 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1,7 +1,7 @@ //! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint //! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes -//! polylines and cubic splines with a lineweight and dashes, or filled outlines where the look -//! needs them. +//! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or +//! filled outlines where the look needs them. use std::collections::HashMap; use std::io::Cursor; @@ -66,6 +66,8 @@ struct CircularSub { centre: Point, radius: f64, samples: Vec, + /// The signed angle (radians) the curve turns through about `centre` in document space. + turn: f64, } fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point { @@ -122,7 +124,12 @@ fn circular_subpath(sub: &Sub) -> Option { if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 { return None; } - let tolerance = (radius * 5e-4).max(1e-6); + // The tolerance is a fraction of the smaller of the radius and the curve's extent, so the large + // radius of a shallow curve does not loosen it. + let (lo, hi) = samples + .iter() + .fold((samples[0], samples[0]), |(lo, hi), p| (Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y)))); + let tolerance = (radius.min(lo.distance(hi)) * 5e-4).max(1e-6); if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) { return None; } @@ -181,7 +188,76 @@ fn circular_subpath(sub: &Sub) -> Option { } else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 { return None; } - Some(CircularSub { centre, radius, samples }) + Some(CircularSub { centre, radius, samples, turn: total_turn }) +} + +/// The points of the circle about `centre` of `radius` furthest left, right, up and down that lie on +/// its arc from `from` through `turn` radians, for the drawing extents. +fn arc_extrema(centre: Point, radius: f64, from: Point, turn: f64) -> Vec { + let start = (from.y - centre.y).atan2(from.x - centre.x); + (0..4) + .map(|k| f64::from(k) * std::f64::consts::FRAC_PI_2) + .filter(|a| ((a - start) * turn.signum()).rem_euclid(std::f64::consts::TAU) <= turn.abs()) + .map(|a| Point::new(centre.x + radius * a.cos(), centre.y + radius * a.sin())) + .collect() +} + +/// The most cubics one bulge arc is fitted to; the cubics after them start the next arc. +const MAX_ARC_RUN: usize = 64; + +/// A subpath of straight segments and circular arcs as polyline vertices, in document space. +struct BulgedSub { + /// The anchors, without a closed subpath's repeated start. + pts: Vec, + /// For each anchor, the angle (radians, document space) the segment that starts there turns + /// through: 0 for a straight segment. + turns: Vec, + /// The arcs' extreme points ([`arc_extrema`]), for the drawing extents. + extrema: Vec, +} + +/// Fit a subpath with curves to straight segments and circular arcs: each run of cubics on one +/// circle, turning one way and less than a whole turn, becomes one arc ([`circular_subpath`]'s +/// tolerance). `None` when the subpath has no curves or a curve that is not a circular arc. +fn bulged_subpath(sub: &Sub) -> Option { + if !sub.segs.iter().any(|s| matches!(s, Seg::Cubic(..))) { + return None; + } + let mut pts = vec![sub.start]; + let mut turns = vec![]; + let mut extrema = vec![]; + let mut from = sub.start; + let mut i = 0; + while let Some(seg) = sub.segs.get(i) { + if let Seg::Line(p) = *seg { + turns.push(0.0); + pts.push(p); + from = p; + i += 1; + continue; + } + let mut best = None; + let mut j = i; + while j < sub.segs.len() && j - i < MAX_ARC_RUN && matches!(sub.segs[j], Seg::Cubic(..)) { + let run = Sub { start: from, segs: sub.segs[i..=j].to_vec(), closed: false }; + let Some(arc) = circular_subpath(&run) else { break }; + best = Some((j, arc)); + j += 1; + } + let (j, arc) = best?; + turns.push(arc.turn); + extrema.extend(arc_extrema(arc.centre, arc.radius, from, arc.turn)); + from = sub.segs[j].end(); + pts.push(from); + i = j + 1; + } + if sub.closed && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) { + pts.pop(); + } else { + // The segment from the last anchor: the straight closing edge, or none for an open subpath. + turns.push(0.0); + } + (pts.len() >= 2).then_some(BulgedSub { pts, turns, extrema }) } /// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle @@ -846,7 +922,9 @@ impl<'a> Scene<'a> { } /// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for - /// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones. + /// other straight subpaths, circles, arcs and ellipses where the curves are one, polylines + /// with bulges for subpaths of straight segments and circular arcs, and cubic splines for + /// other curved ones (flattened polylines in R12). /// `width`: the stroke weight, which DXF before 2000 gives polylines as a width. fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) { let v = self.o.version; @@ -863,7 +941,7 @@ impl<'a> Scene<'a> { // A LINE has no width, so a stroke before 2000 keeps its polyline width. match (sub.closed, width, pts.as_slice()) { (false, None, &[a, b]) => self.line(a, b, style), - _ => self.polyline(&pts, sub.closed, style, width), + _ => self.polyline(&pts, &[], sub.closed, style, width), } } else if (v.handles() || width.is_none()) && self.circular(&sub, style) { // Native circles/arcs are preferred by CAD/CAM software. In R12, however, a @@ -871,6 +949,12 @@ impl<'a> Scene<'a> { // its width. Non-circular curves continue through the existing fallback. } else if v.handles() && self.elliptical(&sub, style) { // R13 and later write an ellipse as a native ELLIPSE. + } else if let Some(b) = bulged_subpath(&sub) { + // Straight segments and circular arcs: one polyline whose bulges draw the arcs. + b.extrema.iter().for_each(|p| _ = self.pt(*p)); + let flip = if self.to_dxf.determinant() < 0.0 { -1.0 } else { 1.0 }; + let bulges: Vec = b.turns.iter().map(|t| flip * (t / 4.0).tan()).collect(); + self.polyline(&b.pts, &bulges, sub.closed, style, width); } else if v.handles() { self.spline(&sub, style); } else { @@ -883,7 +967,7 @@ impl<'a> Scene<'a> { } } for pts in polygons(&path) { - self.polyline(&pts, sub.closed, style, width); + self.polyline(&pts, &[], sub.closed, style, width); } } } @@ -900,8 +984,9 @@ impl<'a> Scene<'a> { } /// A polyline through `pts` (document space): lightweight from R14, else a polyline with a - /// vertex entity per point. - fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option) { + /// vertex entity per point. `bulges`: the bulge (group 42) of the segment that starts at each + /// point, in drawing space; empty for straight segments only. + fn polyline(&mut self, pts: &[Point], bulges: &[f64], closed: bool, style: &EntityStyle, width: Option) { if pts.len() < 2 { return; } @@ -917,8 +1002,11 @@ impl<'a> Scene<'a> { if let Some(w) = width { self.e.num(43, w); } - for p in &pts { + for (i, p) in pts.iter().enumerate() { self.e.xy(10, p.x, p.y); + if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) { + self.e.num(42, b); + } } return; } @@ -930,13 +1018,16 @@ impl<'a> Scene<'a> { self.e.num(40, w); self.e.num(41, w); } - for p in &pts { + for (i, p) in pts.iter().enumerate() { let vh = self.handle(); self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex"); if self.o.version.handles() { self.e.raw(100, "AcDb2dVertex"); } self.e.xyz(10, p.x, p.y); + if let Some(&b) = bulges.get(i).filter(|b| **b != 0.0) { + self.e.num(42, b); + } } let end = self.handle(); self.e.entity("SEQEND", end, h, &layer, style, ""); @@ -960,8 +1051,8 @@ impl<'a> Scene<'a> { return false; } - // Count sampled points in the drawing extents. For a full circle include all cardinal - // points exactly; for an arc, the dense samples conservatively cover its visible span. + // Count the curve's extreme points in the drawing extents: all four cardinal points of a + // full circle; an arc's ends and the cardinal points on it. if sub.closed { let r = circle.radius; for p in [ @@ -973,8 +1064,8 @@ impl<'a> Scene<'a> { self.pt(p); } } else { - for p in &circle.samples { - self.pt(*p); + for p in [sub.start, last].into_iter().chain(arc_extrema(circle.centre, circle.radius, sub.start, circle.turn)) { + self.pt(p); } } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index e4984c333..d99858511 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -275,6 +275,197 @@ fn circles_and_circular_arcs_are_native_dxf_entities() { } } +/// A cubic arc about `c` of radius `r` from `deg0` to `deg1` (degrees, y down), from the current +/// point, which is the arc's start. +fn arc_cubic(b: &mut vectorcraft_geom::BezPath, c: Point, r: f64, deg0: f64, deg1: f64) { + let (a0, a1) = (deg0.to_radians(), deg1.to_radians()); + let k = 4.0 / 3.0 * ((a1 - a0) / 4.0).tan() * r; + let (p0, p3) = (Point::new(c.x + r * a0.cos(), c.y + r * a0.sin()), Point::new(c.x + r * a1.cos(), c.y + r * a1.sin())); + b.curve_to(Point::new(p0.x - k * a0.sin(), p0.y + k * a0.cos()), Point::new(p3.x + k * a1.sin(), p3.y - k * a1.cos()), p3); +} + +/// A slot (two lines, two half circles) about y = 50, a D shape (a half circle closed by its +/// diameter) and a rounded rectangle with 10 pt corners. +fn lines_and_arcs() -> [PathData; 3] { + let mut slot = vectorcraft_geom::BezPath::new(); + slot.move_to((60.0, 30.0)); + slot.line_to((140.0, 30.0)); + arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, -90.0, 0.0); + arc_cubic(&mut slot, Point::new(140.0, 50.0), 20.0, 0.0, 90.0); + slot.line_to((60.0, 70.0)); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0); + slot.close_path(); + let mut d = vectorcraft_geom::BezPath::new(); + d.move_to((150.0, 20.0)); + arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, -90.0, 0.0); + arc_cubic(&mut d, Point::new(150.0, 50.0), 30.0, 0.0, 90.0); + d.close_path(); + [PathData::from_bezpath(&slot), PathData::from_bezpath(&d), shapes::rounded_rectangle(Rect::new(20.0, 20.0, 100.0, 80.0), 10.0)] +} + +/// A polyline entity's vertices as (x, y, bulge): an LWPOLYLINE's, or those of the VERTEX +/// entities that follow a POLYLINE at `e[i]`. +fn vertices(e: &[Pairs], i: usize) -> Vec<(f64, f64, f64)> { + let mut out = vec![]; + let mut read = |o: &Pairs| { + for (code, value) in o { + let v: f64 = value.trim().parse().unwrap_or(0.0); + match code { + 10 => out.push((v, 0.0, 0.0)), + 20 => out.last_mut().expect("x before y").1 = v, + 42 => out.last_mut().expect("a vertex before its bulge").2 = v, + _ => {} + } + } + }; + if kind(&e[i]) == "LWPOLYLINE" { + read(&e[i]); + } else { + e[i + 1..].iter().take_while(|o| kind(o) == "VERTEX").for_each(&mut read); + } + out +} + +#[test] +fn paths_of_lines_and_circular_arcs_are_polylines_with_bulges() { + let [slot, d_shape, rounded] = lines_and_arcs(); + let d = doc_with(|d| { + let black = || Paint::solid(Color::BLACK); + let mut open = vectorcraft_geom::BezPath::new(); + open.move_to((10.0, 90.0)); + open.line_to((30.0, 90.0)); + arc_cubic(&mut open, Point::new(30.0, 80.0), 10.0, 90.0, 0.0); + vec![ + path(d, slot, Paint::None, black(), 1.0), + path(d, d_shape, Paint::None, black(), 1.0), + path(d, rounded, Paint::None, black(), 1.0), + path(d, PathData::from_bezpath(&open), Paint::None, black(), 1.0), + ] + }); + let b = (std::f64::consts::PI / 8.0).tan(); + for version in DxfVersion::ALL { + let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap(); + let e = entities(&out); + let polys: Vec = (0..e.len()).filter(|i| matches!(kind(&e[*i]), "LWPOLYLINE" | "POLYLINE")).collect(); + let k = kinds(&out); + assert_eq!(polys.len(), 4, "{version:?}: {k:?}"); + assert!(!k.iter().any(|k| matches!(k.as_str(), "SPLINE" | "ARC" | "CIRCLE")), "{version:?}: {k:?}"); + let want = if version.lwpolyline() { "LWPOLYLINE" } else { "POLYLINE" }; + assert!(polys.iter().all(|i| kind(&e[*i]) == want), "{version:?}: {k:?}"); + let flags: Vec = polys.iter().map(|i| get(&e[*i], 70).unwrap().parse::().unwrap() & 1).collect(); + assert_eq!(flags, [1, 1, 1, 0], "{version:?}: closed, closed, closed, open"); + // The slot's half circles turn clockwise with y up: a bulge of -1 after each long side. + assert_eq!(vertices(&e, polys[0]), [(60.0, 70.0, 0.0), (140.0, 70.0, -1.0), (140.0, 30.0, 0.0), (60.0, 30.0, -1.0)], "{version:?}"); + // The D shape: the half circle, then the straight closing edge. + assert_eq!(vertices(&e, polys[1]), [(150.0, 80.0, -1.0), (150.0, 20.0, 0.0)], "{version:?}"); + // The rounded rectangle: eight anchors, a quarter circle (bulge tan 22.5°) at each corner. + let r = vertices(&e, polys[2]); + assert_eq!(r.len(), 8, "{version:?}: {r:?}"); + let bulges: Vec = r.iter().map(|v| v.2).filter(|b| *b != 0.0).collect(); + assert!(close_to(&bulges, &[-b; 4], 1e-6), "{version:?}: {r:?}"); + // An open line then a quarter circle turning counterclockwise with y up. + let o = vertices(&e, polys[3]); + assert!( + close_to(&o.iter().flat_map(|v| [v.0, v.1, v.2]).collect::>(), &[10.0, 10.0, 0.0, 30.0, 10.0, b, 40.0, 20.0, 0.0], 1e-6), + "{version:?}: {o:?}" + ); + } +} + +#[test] +fn polylines_with_bulges_read_back_as_the_same_outline() { + for (i, shape) in lines_and_arcs().into_iter().enumerate() { + let original = shape.to_bezpath(); + let d = doc_with(|d| vec![path(d, shape, Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), ["LWPOLYLINE"], "shape {i}"); + let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }; + let back = import(&out.bytes, &o).unwrap().document; + let mut paths = vec![]; + back.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + paths.push(path.to_bezpath()); + } + }); + assert_eq!(paths.len(), 1, "shape {i}"); + assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "shape {i} reads back closed"); + let (a, b) = (original.bounding_box(), paths[0].bounding_box()); + assert!(close_to(&[a.width(), a.height()], &[b.width(), b.height()], 1e-2), "shape {i}: {a:?} against {b:?}"); + // Every point read back lies on the original outline, once both are centered. + let shift = a.center() - b.center(); + let outline: Vec = original.segments().flat_map(|s| (0..=64).map(move |t| s.eval(f64::from(t) / 64.0))).collect(); + for seg in paths[0].segments() { + for t in 0..=16 { + let p = seg.eval(f64::from(t) / 16.0) + shift; + let near = outline + .windows(2) + .map(|w| { + let (u, v) = (w[1] - w[0], p - w[0]); + let s = if u.hypot2() > 0.0 { (v.dot(u) / u.hypot2()).clamp(0.0, 1.0) } else { 0.0 }; + (v - u * s).hypot() + }) + .fold(f64::INFINITY, f64::min); + assert!(near < 1e-2, "shape {i}: {p:?} is {near} pt off the outline"); + } + } + } +} + +#[test] +fn a_path_with_a_curve_that_is_not_a_circular_arc_stays_a_spline() { + // The slot with one end drawn by a longer-handled curve. + let mut slot = vectorcraft_geom::BezPath::new(); + slot.move_to((60.0, 30.0)); + slot.line_to((140.0, 30.0)); + slot.curve_to((165.0, 30.0), (165.0, 70.0), (140.0, 70.0)); + slot.line_to((60.0, 70.0)); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 90.0, 180.0); + arc_cubic(&mut slot, Point::new(60.0, 50.0), 20.0, 180.0, 270.0); + slot.close_path(); + let d = doc_with(|d| { + vec![ + path(d, PathData::from_bezpath(&slot), Paint::None, Paint::solid(Color::BLACK), 1.0), + path(d, shapes::ellipse(Rect::new(150.0, 20.0, 190.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0), + ] + }); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "CIRCLE"]); +} + +#[test] +fn drawing_extents_reach_the_extreme_points_of_arcs() { + // An arc about (100, 50) of radius 40 from -60° to 30° passes x = 140 at 0°, between the points + // along it that recognition samples. + let arc = |closed: bool| { + let mut b = vectorcraft_geom::BezPath::new(); + let start = Point::new(100.0 + 40.0 * 60f64.to_radians().cos(), 50.0 - 40.0 * 60f64.to_radians().sin()); + b.move_to(start); + arc_cubic(&mut b, Point::new(100.0, 50.0), 40.0, -60.0, 30.0); + if closed { + b.close_path(); + } + PathData::from_bezpath(&b) + }; + for (closed, entity) in [(false, "ARC"), (true, "LWPOLYLINE")] { + let d = doc_with(|d| vec![path(d, arc(closed), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + let out = export(&d, &opts(&d)).unwrap(); + assert_eq!(kinds(&out), [entity]); + let x: f64 = header(&pairs(&out.bytes), "$EXTMAX").unwrap().trim().parse().unwrap(); + assert!((x - 140.0).abs() < 1e-6, "{entity}: $EXTMAX x {x}"); + } +} + +#[test] +fn a_shallow_curve_that_is_not_circular_is_not_an_arc() { + // 100 pt across and 1.5 pt deep: the circle through its ends and middle has a radius of about + // 834 pt, and the curve is 0.12 pt off it a quarter of the way along. + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to((0.0, 50.0)); + b.curve_to((20.0, 48.0), (80.0, 48.0), (100.0, 50.0)); + let d = doc_with(|d| vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE"]); +} + /// The path `p` with `xf` applied. fn transformed(p: &PathData, xf: Affine) -> PathData { PathData::from_bezpath(&(xf * p.to_bezpath())) @@ -391,11 +582,12 @@ fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() { vec![ path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0), path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0), - // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). + // An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6). Its + // quarters fit shallow circular arcs within the tolerance, so it is a polyline with bulges. path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0), ] }); - assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]); + assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "LWPOLYLINE"]); } #[test] @@ -462,8 +654,12 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() { vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)] }); + // The three quarters turn counterclockwise with y up (bulge tan 67.5°), then the chord closes it. let modern = export(&d, &opts(&d)).unwrap(); - assert_eq!(kinds(&modern), ["SPLINE"]); + assert_eq!(kinds(&modern), ["LWPOLYLINE"]); + let v = vertices(&entities(&modern), 0); + let flat: Vec = v.iter().flat_map(|v| [v.0, v.1, v.2]).collect(); + assert!(close_to(&flat, &[80.0, 50.0, 67.5f64.to_radians().tan(), 50.0, 20.0, 0.0], 1e-6), "{v:?}"); let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap(); assert!(kinds(&r12).iter().any(|k| k == "POLYLINE")); assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE")); @@ -497,9 +693,13 @@ fn a_closed_sliver_along_a_circle_is_not_a_circle() { vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)] }); + // Two arcs, out along the circle and back: one polyline with a bulge each way. let modern = export(&d, &opts(&d)).unwrap(); assert_ne!(kinds(&modern), ["CIRCLE"]); - assert_eq!(kinds(&modern), ["SPLINE"]); + assert_eq!(kinds(&modern), ["LWPOLYLINE"]); + let bulges: Vec = vertices(&entities(&modern), 0).iter().map(|v| v.2).collect(); + let b = 10f64.to_radians().tan(); + assert!(close_to(&bulges, &[-b, b], 1e-6) || close_to(&bulges, &[b, -b], 1e-6), "{bulges:?}"); } #[test] diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 652db3340..794f92aee 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -86,6 +86,9 @@ const TINT_STEPS: u32 = 16; const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0; /// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for. const MAX_INK_TINTS: usize = 4096; +/// How many even steps [`Gradient::ink_mixes`] checks the lightness of two stops' display RGB mix +/// at, to tell whether it moves steadily from one stop's lightness to the other's. +const INK_MIX_STEPS: u32 = 64; /// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly /// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved @@ -401,8 +404,10 @@ impl Gradient { /// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab /// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix /// position itself when the two colors' lightness differs by less than - /// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than - /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). + /// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere + /// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even + /// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more + /// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]). pub fn ink_mixes(&self) -> Option> { use crate::libraries::srgb_to_oklab; if self.method.is_classic() || !self.fits_ink_tints() { @@ -414,8 +419,16 @@ impl Gradient { out.push((s.offset, i, 0.0, s.opacity)); let Some(n) = self.stops.get(i + 1) else { continue }; let (l0, l1) = (lightness(&s.color), lightness(&n.color)); + let mixed_lightness = |w: f32| lightness(&GradientMethod::Classic.mix(&s.color, &n.color, w)); + // The mix position itself when the lightness doesn't tell the mixes apart. + let by_position = (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN + || !(0..=INK_MIX_STEPS) + .map(|k| mixed_lightness(k as f32 / INK_MIX_STEPS as f32)) + .collect::>() + .windows(2) + .all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0); for v in perceptual_mixes(s, n) { - let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN { + let weight = if by_position { v } else { let want = lightness(&self.method.mix(&s.color, &n.color, v)); @@ -423,7 +436,7 @@ impl Gradient { for _ in 0..TINT_STEPS { let mid = (lo + hi) / 2.0; // The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one. - if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 { + if (mixed_lightness(mid) - want) * (l1 - l0) < 0.0 { lo = mid; } else { hi = mid; diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index 1e75feb33..1005b8430 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -585,3 +585,22 @@ fn ink_mixes_follow_the_perceptual_curve() { g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect(); assert_eq!(g.ink_mixes(), None); } + +/// From red to blue the display RGB mix darkens past blue's lightness and comes back, so a +/// weight found by lightness near the blue stop would be a red-side one and the inks would jump +/// there. The weights are the mix positions instead, and reach the blue stop's inks steadily. +#[test] +fn ink_mixes_use_the_mix_position_when_the_lightness_turns_back() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red), GradientStop::new(1.0, blue)]); + g.method = GradientMethod::Perceptual; + let mixes = g.ink_mixes().expect("a Perceptual gradient's curve"); + assert_eq!(mixes[mixes.len() - 1], (1.0, 1, 0.0, 1.0), "the blue stop"); + let between: Vec<_> = mixes.iter().filter(|m| m.1 == 0 && m.0 > 0.0).collect(); + assert!(!between.is_empty(), "stops on the curve between red and blue: {mixes:?}"); + // With the midpoint at ½, a stop's offset on the curve is its mix position. + for (t, _, w, _) in &between { + assert!((w - t).abs() < 1e-6, "at {t}: weight {w}"); + } + assert!(mixes.windows(2).filter(|p| p[1].1 == 0).all(|p| p[0].2 <= p[1].2), "the weights rise: {mixes:?}"); +} diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 1c3334f0d..01b9c9fc5 100644 --- a/crates/doc/src/appearance/profile.rs +++ b/crates/doc/src/appearance/profile.rs @@ -34,11 +34,11 @@ impl WidthProfile { if t <= p[0].0 { return (p[0].1, p[0].2); } - for w in p.windows(2) { - if t <= w[1].0 { - let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9); - return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u); - } + // (Positions go forward; found in log time, so a long profile read from a file is cheap to ask.) + let i = p.partition_point(|q| q.0 < t); + if let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { + let u = (t - a.0) / (b.0 - a.0).max(1e-9); + return (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u); } p.last().map_or((1.0, 1.0), |l| (l.1, l.2)) } @@ -90,10 +90,12 @@ impl WidthProfile { /// discontinuous point (two points at `t`, within `1e-7`). pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) { let p = &self.points; - if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) { - let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1; + // The same distance test below, so a point exactly 1e-7 before `t` is never skipped by rounding. + let i = p.partition_point(|q| q.0 < t && (q.0 - t).abs() > 1e-7); + if let Some(first) = p.get(i).filter(|q| (q.0 - t).abs() <= 1e-7) { + let j = i + p[i..].partition_point(|q| q.0 - first.0 <= 1e-9) - 1; if j > i { - return ((p[i].1, p[i].2), (p[j].1, p[j].2)); + return ((first.1, first.2), (p[j].1, p[j].2)); } } let v = self.at(t); @@ -334,3 +336,26 @@ pub struct SavedProfile { pub name: String, pub profile: WidthProfile, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn width_profile_lookups_by_search() { + let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25), (1.0, 0.0, 0.0)] }; + assert_eq!(p.at(-1.0), (0.0, 0.0)); + assert_eq!(p.at(0.25), (0.5, 0.5)); + assert_eq!(p.at(0.75), (0.25, 0.125)); + assert_eq!(p.at(2.0), (0.0, 0.0)); + // A step: the widths just before and just after it. + assert_eq!(p.around(0.5), ((1.0, 1.0), (0.5, 0.25))); + assert_eq!(p.around(0.25), ((0.5, 0.5), (0.5, 0.5))); + // Many points at one position are one step, found without a pass over them all. + let many = WidthProfile { points: (0..1000).map(|i| (0.5, i as f64, i as f64)).collect() }; + assert_eq!(many.around(0.5), ((0.0, 0.0), (999.0, 999.0))); + // 0.4999999 == 0.5 - 1e-7, yet its distance from 0.5 rounds above 1e-7: it is not part of the step. + let near = WidthProfile { points: vec![(0.4999999, 2.0, 2.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25)] }; + assert_eq!(near.around(0.5), ((1.0, 1.0), (0.5, 0.25))); + } +} diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index c9a6c5f56..efd7f80b2 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -99,13 +99,15 @@ impl Document { } /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working - /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). + /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The + /// layer is removed wherever it is, also as a sublayer of another layer; an id that names + /// anything other than a layer is left alone. pub fn drop_edit_modes(&mut self) { - if let Some(me) = self.mask_edit.take() { - self.layers.retain(|l| l.id != me.layer); - } - if let Some(se) = self.symbol_edit.take() { - self.layers.retain(|l| l.id != se.layer); + let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)]; + for id in layers.into_iter().flatten() { + if self.node(id).is_some_and(Node::is_layer) { + let _ = self.remove(id); + } } } diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 34c2a1dd9..b19adc8b0 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -314,6 +314,13 @@ pub struct SeriesPaint { /// Stroke weight in points, kept with [`Self::stroke`]. #[serde(skip_serializing_if = "Option::is_none")] pub stroke_width: Option, + /// Opacity (0..1) of the topmost fill of each path directly in the series group (graph designs keep their own), + /// taken from the topmost fill row edited on one of them. Absent: fully opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_opacity: Option, + /// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_opacity: Option, } /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each diff --git a/crates/doc/src/halftone_radii.rs b/crates/doc/src/halftone_radii.rs index cbafeb0b9..5828f543e 100644 --- a/crates/doc/src/halftone_radii.rs +++ b/crates/doc/src/halftone_radii.rs @@ -1,4 +1,4 @@ -//! Color Halftone's Max Radius was a distance in points until documents of version 2; since then +//! Color Halftone's Max Radius was a distance in points until documents of version 3; since then //! it is in pixels of the Document Raster Effects resolution, as public documentation gives it //! (#3260). [`Document::migrate_halftone_radii`] converts the radii of older documents, and //! [`Document::halftone_radii_to_points`] converts them back for a file saved for an older app. @@ -11,7 +11,7 @@ use crate::limits::FORMAT_VERSION; use crate::{Appearance, Document, Effect, Node}; /// The first document version whose Color Halftone radii are in pixels. -const PIXEL_RADII_SINCE: u32 = 2; +const PIXEL_RADII_SINCE: u32 = 3; const HALFTONE: &str = "pixelate.colorHalftone"; const RADIUS: &str = "maxRadius"; /// The radius (points) an older document's halftone without one drew with. @@ -20,7 +20,7 @@ const OLD_DEFAULT: f64 = 8.0; const RANGE: std::ops::RangeInclusive = 4.0..=127.0; impl Document { - /// Saved before version 2: each Color Halftone's Max Radius in points becomes the pixels of + /// Saved before version 3: each Color Halftone's Max Radius in points becomes the pixels of /// the document's raster effects resolution that draw the same dots, within the 4 to 127 px /// the effect takes (a radius over 127 px, from a large radius at a high resolution, draws /// smaller dots than before). In the art, symbols, patterns, graph designs, opacity masks and @@ -34,10 +34,10 @@ impl Document { self.each_halftone(&mut |e| rescale(e, k)); } - /// For a file saved for an app before version 2 (every native file version before today's): + /// For a file saved for an app before version 3 (every native file version before today's): /// each Color Halftone's Max Radius in pixels becomes the points that draw the same dots, /// within the 4 to 127 pt that app takes (a radius under 4 pt, from a fine screen at a high - /// resolution, draws bigger dots there), and the document becomes version 1, so reopening the + /// resolution, draws bigger dots there), and the document becomes version 2, so reopening the /// file here converts them back. pub fn halftone_radii_to_points(&mut self) { if self.version < PIXEL_RADII_SINCE { @@ -146,7 +146,7 @@ mod tests { } #[test] - fn only_documents_before_version_2_are_converted_once() { + fn only_documents_before_version_3_are_converted_once() { let mut d = Document::new(100.0, 100.0); d.raster_effects_ppi = 300.0; let mut style = d.graphic_styles.first().cloned().unwrap(); @@ -175,7 +175,7 @@ mod tests { d.graphic_styles.push(style); let r = |d: &Document, i: usize| d.graphic_styles.last().map(|s| radius(&s.appearance.effects[i])).unwrap(); d.halftone_radii_to_points(); - assert_eq!(d.version, 1, "reopened here, it converts back"); + assert_eq!(d.version, 2, "reopened here, it converts back"); assert!((r(&d, 0) - 24.0).abs() < 1e-9, "100 px at 300 ppi is 24 pt: {}", r(&d, 0)); assert_eq!(r(&d, 1), 4.0, "0.96 pt is under the 4 pt the older app takes"); let points = d.clone(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4ac86d1a5..9bb8466bb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -41,6 +41,7 @@ pub mod live; pub mod live_paint; pub mod marks; mod mem; +pub use mem::{appearance_heap_bytes, paint_heap_bytes}; pub mod metadata; pub mod node; pub mod objects_on_path; @@ -68,6 +69,7 @@ mod symbols; pub use symbols::SymbolBoxes; pub mod text; pub mod trace; +pub mod twist; pub mod variables; pub mod xml_id; @@ -111,6 +113,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::FontHeightReference; +pub use text::GlyphChoice; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, @@ -227,8 +230,9 @@ pub struct Document { #[serde(default, skip_serializing)] pub mask_edit: Option, /// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the - /// symbol already holds the art of its editing layer. - #[serde(default, skip_serializing)] + /// symbol already holds the art of its editing layer. Never read either: a value in file data + /// is dropped, so the layer it names is kept and saving does not write the value again. + #[serde(default, skip_serializing, deserialize_with = "skip::read_as_none")] pub symbol_edit: Option, next_id: u64, /// Foreign data preserved on round-trip. diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs index c143e545f..240672cca 100644 --- a/crates/doc/src/limits.rs +++ b/crates/doc/src/limits.rs @@ -13,10 +13,11 @@ pub enum DocError { Invalid(String), } -/// The version of the document model new documents are made in: 2 measures Color Halftone's Max -/// Radius in pixels of the raster effects resolution (older documents are converted on opening, +/// The document version new documents get. Version 2: a positive Twist angle turns clockwise +/// ([`crate::twist`]). Version 3: Color Halftone's Max Radius is in pixels of the raster effects +/// resolution (older documents are converted on opening, /// [`crate::Document::migrate_halftone_radii`]). -pub const FORMAT_VERSION: u32 = 2; +pub const FORMAT_VERSION: u32 = 3; /// Node, slice and asset ids are handed out below this: no document gets near it by counting, so /// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a1268505f..a93830822 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -98,7 +98,7 @@ impl Document { vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::() }); n += shared(&self.graphic_styles, &newer.graphic_styles, |g| { - vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::() + vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap_bytes(&g.appearance)).sum::() }); let styles = |s: &Vec| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::(); n += shared(&self.char_styles, &newer.char_styles, styles); @@ -245,7 +245,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize { let mut b = ALLOC + ARC + size_of::(); b += n.name.as_ref().map_or(0, String::capacity); if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) { - b += ALLOC + ARC + appearance_heap(&n.appearance); + b += ALLOC + ARC + appearance_heap_bytes(&n.appearance); } if let Some(e) = n.extras_box() { b += ALLOC + size_of::(); @@ -293,32 +293,53 @@ fn text_bytes(t: &TextObject) -> usize { + vec_bytes(&t.cached_baselines) } -/// The heap an appearance owns (not the appearance itself). -fn appearance_heap(a: &Appearance) -> usize { +/// Approximate retained memory of an appearance and its owned buffers, without serialization. +pub fn appearance_heap_bytes(a: &Appearance) -> usize { size_of::() + vec_bytes(&a.items) + a.items .iter() - .map(|i| match i { - AppearanceItem::Fill(f) => paint_heap(&f.paint), - AppearanceItem::Stroke(s) => paint_heap(&s.paint), + .map(|item| { + let paint = paint_heap_bytes(item.paint()); + let effects = effects_heap(item.effects()); + let stroke = match item { + AppearanceItem::Stroke(s) => { + s.dash.as_ref().map_or(0, |d| vec_bytes(&d.pattern)) + + s.profile.as_ref().map_or(0, |p| vec_bytes(&p.points)) + + s.pressure.as_ref().map_or(0, |p| vec_bytes(&p.points)) + + s.brush.as_ref().map_or(0, String::capacity) + } + _ => 0, + }; + paint + effects + stroke }) .sum::() - + vec_bytes(&a.effects) - + a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::() + + effects_heap(&a.effects) } -fn paint_heap(p: &Paint) -> usize { +fn effects_heap(effects: &Vec) -> usize { + vec_bytes(effects) + effects.iter().map(|e| e.id.capacity() + json_bytes(&e.params)).sum::() +} + +/// Approximate heap retained by one paint, without serializing it. +pub fn paint_heap_bytes(p: &Paint) -> usize { match p { Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity), - Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops), + Paint::Gradient(g) => { + ALLOC + + size_of_val(&**g) + + vec_bytes(&g.gradient.stops) + + g.gradient.stops.iter().map(|s| s.swatch.as_ref().map_or(0, String::capacity)).sum::() + + g.swatch.as_ref().map_or(0, String::capacity) + + g.freeform.as_ref().map_or(0, |f| vec_bytes(&f.points) + vec_bytes(&f.lines) + f.lines.iter().map(vec_bytes).sum::()) + } Paint::Pattern { pattern, .. } => pattern.capacity(), Paint::None => 0, } } fn swatches_bytes(s: &[Swatch]) -> usize { - ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::() + ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap_bytes(&w.paint)).sum::() } /// A vector's buffer (not what its elements own). diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 07899196c..5ab9e2c6c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -369,7 +369,12 @@ impl Node { let placed = xf.transform_rect_bbox(art.geometric); match s.placed_visual(symbol, *xf) { Some(v) => v.union(placed), - None => placed.inflate(art.reach, art.reach), + // Art placed through 9-slice guides can draw outside `placed`. + None => s + .document() + .symbol_slice_map_of(symbol, art.geometric, *xf) + .map_or(placed, |m| m.placement_bounds(art.geometric).union(placed)) + .inflate(art.reach, art.reach), } } None => xf.transform_rect_bbox(SYMBOL_SQUARE), diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 1669de1a2..cde4004ae 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -390,10 +390,19 @@ fn a_projection_follows_the_transform_or_is_removed() { let mut n = instance(far); n.transform(Affine::scale(1e150), false); assert_eq!(projection(&n), None); - // A transform that is not invertible leaves the projection as it is. + // A transform that is not invertible leaves the projection as it is, at any scale. let mut n = instance(far); n.transform(Affine::scale(0.0), false); assert_eq!(projection(&n), Some(far.to_array())); + let mut n = instance(far); + n.transform(Affine::new([1e-8, 2e-8, 2e-8, 4e-8, 0.0, 0.0]), false); + assert_eq!(projection(&n), Some(far.to_array())); + // A scale by 1e-8 is invertible: a move by 10 becomes a move by 1e-7. + let ten = Homography::from_affine(Affine::translate((10.0, 0.0))); + let mut n = instance(ten); + n.transform(Affine::scale(1e-8), false); + let q = n.projection().unwrap().apply(Point::new(3e-8, 7e-8)).unwrap(); + assert!(q.distance(Point::new(1.3e-7, 7e-8)) < 1e-20, "{q:?}"); } #[test] diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs index 6ec18bcfa..ab347529e 100644 --- a/crates/doc/src/perspective.rs +++ b/crates/doc/src/perspective.rs @@ -63,7 +63,7 @@ impl Node { pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut()) && let Some(h) = p.homography() - && a.determinant().abs() >= 1e-15 + && Homography::affine_is_invertible(a) { p.projection = h.conjugated(a).map(|h| h.to_array()); } diff --git a/crates/doc/src/skip.rs b/crates/doc/src/skip.rs index 15f294c00..b26a1a7ac 100644 --- a/crates/doc/src/skip.rs +++ b/crates/doc/src/skip.rs @@ -13,3 +13,8 @@ pub fn is_empty_vec(v: &[T]) -> bool { pub fn is_empty_map(v: &serde_json::Map) -> bool { v.is_empty() } +/// Read a field's value and drop it, giving `None`: for state that is never taken from a file. +pub fn read_as_none<'de, D: serde::Deserializer<'de>, T>(d: D) -> Result, D::Error> { + ::deserialize(d)?; + Ok(None) +} diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 30aae49e1..6eda3acef 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -98,6 +98,10 @@ impl GraphicStyle { /// The extension (and `format` header) of graphic style library files. pub const STYLES_EXT: &str = "vcstyles"; +/// The version of the `.vcstyles` files [`write`] writes. Version 2: a positive Twist angle turns +/// clockwise ([`crate::twist`]). +const STYLES_VERSION: u32 = crate::twist::TWIST_CLOCKWISE_SINCE; + /// The file: a header around the library. #[derive(Serialize, Deserialize)] struct NativeFile { @@ -109,17 +113,21 @@ struct NativeFile { /// `lib` as a `.vcstyles` file. pub fn write(lib: &StyleLibrary) -> String { - let file = NativeFile { format: STYLES_EXT.into(), version: 1, library: lib.clone() }; + let file = NativeFile { format: STYLES_EXT.into(), version: STYLES_VERSION, library: lib.clone() }; serde_json::to_string_pretty(&file).unwrap_or_default() } -/// Read a `.vcstyles` file; an unnamed library is called `name`. +/// Read a `.vcstyles` file; an unnamed library is called `name`. A version 1 file was written by +/// an app that turned Twist counterclockwise: its Twist angles are negated. pub fn read(text: &str, name: &str) -> Result { let f: NativeFile = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("not a graphic style library: {e}"))?; if f.format != STYLES_EXT { return Err(format!("not a graphic style library (format `{}`)", f.format)); } let mut lib = f.library; + if f.version < STYLES_VERSION { + crate::twist::negate_library(&mut lib); + } if lib.name.trim().is_empty() { lib.name = name.into(); } diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 8d7c108ab..acc682872 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -343,6 +343,20 @@ impl NineSliceMap { self.cell_xf(self.x.cell(q.x), self.y.cell(q.y)) } + /// A box that holds everything [`Self::place`] draws of art whose geometric box in symbol space + /// is `art`, before strokes and effects: `art` placed by each of the nine cells' maps. An object + /// that crosses a guide and draws symbol art is placed by the map of the cell its center is in, + /// so it can draw outside the box the corner cells place `art` in. + pub fn placement_bounds(&self, art: Rect) -> Rect { + let mut b = self.cell_xf(0, 0).transform_rect_bbox(art); + for j in 0..3 { + for i in 0..3 { + b = b.union(self.cell_xf(i, j).transform_rect_bbox(art)); + } + } + b + } + /// The guides in symbol space: the two x values and the two y values. fn cuts(&self) -> ([f64; 2], [f64; 2]) { let inv = self.natural.inverse(); @@ -704,6 +718,12 @@ impl Document { NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf) } + /// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already + /// measured. + pub(crate) fn symbol_slice_map_of(&self, name: &str, art: Rect, xf: Affine) -> Option { + NineSliceMap::new(self.symbol_nine_slice(name)?, art, self.symbol_natural_xf(name), xf) + } + /// Art `art` of symbol `name` (stained as the caller draws it) placed by an instance's /// transform `xf`, with strokes at their own weight: through the symbol's 9-slice guides when /// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 7e371c670..3a91f8d62 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -102,7 +102,9 @@ impl<'a> SymbolBoxes<'a> { /// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its /// art box alone. An instance that scales or turns stroked art then measures a little larger - /// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. + /// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. The + /// visual box of an instance placed through 9-slice guides holds the art box placed by each + /// cell's map ([`crate::NineSliceMap::placement_bounds`]). pub fn quick(self) -> Self { self.quick.set(true); self @@ -447,6 +449,25 @@ mod tests { assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach))); } + #[test] + fn quick_boxes_hold_art_that_9_slice_guides_move_past_the_instance() { + // A 100 × 100 frame with guides a quarter in, holding an instance of a bar from x 10 to 70. + // Scaled 2 times across, the bar crosses the left guide and is placed by the middle + // column's map, x → 3x − 50: it draws from x −20 to 160, past the frame's left edge at 0. + let mut d = Document::new(200.0, 200.0); + let bar = rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), 0.0); + symbol(&mut d, "Bar", vec![bar]); + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + let inner = instance(&mut d, "Bar", Affine::IDENTITY); + symbol(&mut d, "Panel", vec![frame, inner]); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let panel = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + let exact = panel.visual_bounds_with(&d.symbol_boxes()).unwrap(); + assert_eq!(exact, Rect::new(-20.0, 0.0, 200.0, 100.0)); + let quick = panel.visual_bounds_with(&d.symbol_boxes().quick()).unwrap(); + assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}"); + } + #[test] fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 37e2d39bc..4b75b0149 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -451,3 +451,64 @@ fn pica_form_rejects_exponent_notation() { assert_eq!(Unit::Points.parse(s), None, "{s}"); } } + +/// Symbol editing of symbol "S" on `layer`. +fn symbol_edit_on(layer: NodeId) -> SymbolEdit { + SymbolEdit { + symbol: "S".into(), + layer, + instance: None, + xf: Affine::IDENTITY, + group: false, + changed: false, + back_isolation: None, + back_layer: None, + } +} + +/// The opacity-mask and symbol editing layers are left out of the saved document wherever they +/// are, also when `layer.move` made them sublayers of another layer. +#[test] +fn edit_mode_layers_are_dropped_as_sublayers_too() { + let mut d = Document::new(612.0, 792.0); + let host = d.layers[0].id; + let mask = d.add_layer(Some("Mask")); + let symbol = d.add_layer(Some("Symbol")); + d.move_node(mask, Some(host), 0).unwrap(); + d.move_node(symbol, Some(host), usize::MAX).unwrap(); + let object = d.alloc_id(); + d.mask_edit = Some(MaskEdit { object, layer: mask }); + d.symbol_edit = Some(symbol_edit_on(symbol)); + let saved = d.without_edit_modes(); + assert!(saved.node(mask).is_none(), "the mask editing layer is left out"); + assert!(saved.node(symbol).is_none(), "the symbol editing layer is left out"); + assert!(saved.node(host).is_some_and(|l| l.children().is_some_and(|c| c.is_empty()))); + assert!(saved.mask_edit.is_none() && saved.symbol_edit.is_none()); +} + +/// Symbol editing is never read from a file, so a layer that file data names as the editing layer +/// loads and stays in the document, and saving does not write the value again. +#[test] +fn symbol_edit_is_not_read_from_a_file() { + let mut d = Document::new(612.0, 792.0); + let layer = d.layers[0].id; + let mut v = serde_json::to_value(&d).unwrap(); + v["symbol_edit"] = serde_json::to_value(symbol_edit_on(layer)).unwrap(); + d = serde_json::from_value(v).unwrap(); + assert_eq!(d.symbol_edit, None); + assert!(d.extra.is_empty(), "not kept as an unknown key"); + d.drop_edit_modes(); + assert!(d.node(layer).is_some(), "the layer is kept"); + assert!(serde_json::to_value(&d).unwrap().get("symbol_edit").is_none(), "not saved again"); +} + +/// An opacity-mask editing id in file data that names art inside a layer, not a layer, leaves the +/// art in place. +#[test] +fn mask_edit_naming_art_leaves_it() { + let (mut d, a, b) = doc_with_rects(); + d.mask_edit = Some(MaskEdit { object: b, layer: a }); + d.drop_edit_modes(); + assert!(d.node(a).is_some() && d.node(b).is_some()); + assert_eq!(d.mask_edit, None); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 064ecbda1..a2f21e856 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -16,7 +16,7 @@ mod wrap; pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape}; use char_style::yes; -pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics}; +pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics}; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index 5461f005e..731480fed 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -147,6 +147,28 @@ pub struct CharStyle { /// document's language (Document Setup). Saved only when set. #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, + /// Another glyph of the font for the run's first character (Glyphs panel › Show › Alternates + /// for Current Selection). The text keeps the character; layout draws the chosen glyph while + /// the run starts with that character and is set in the face the choice names, and the + /// default glyph otherwise. Saved only when set. + #[serde(default, rename = "glyphAlternate", skip_serializing_if = "Option::is_none")] + pub glyph_alternate: Option, +} + +/// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it +/// belongs to, named by its PostScript name and version string, so that a glyph id is never used +/// in another font or another version of the font. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct GlyphChoice { + /// The character the glyph is drawn for. + pub ch: char, + /// The glyph id in the face. + pub gid: u32, + /// The face's PostScript name (`SourceSans3-Regular`). + pub font: String, + /// The face's version string (`Version 3.052`); empty when it has none. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub version: String, } /// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square @@ -420,6 +442,7 @@ impl Default for CharStyle { font_height_reference: FontHeightReference::EmBox, no_break: false, language: None, + glyph_alternate: None, } } } diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index c6a22a29c..70e953922 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -200,14 +200,16 @@ impl TextObject { } /// 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. + /// `at` is a list's start followed by a space, and the paragraph is in no list. None otherwise, + /// and for a first paragraph that continues from the frame before + /// ([`TextObject::first_para_continues`]). 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)) + (!(index == 0 && self.first_para_continues) && 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)) { diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index 04deae8ee..2a47ee453 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -239,6 +239,19 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() { assert_eq!(t.typed_list_at(19), Some((16, letters))); } +/// In a threaded frame whose first paragraph continues from the frame before, the text at the +/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing. +/// The paragraphs after it still start lists. +#[test] +fn typed_list_starts_skip_a_continued_paragraph() { + let dot = ParaList::bulleted(Bullet::Dot); + let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default()); + assert_eq!(t.typed_list_at(2), Some((0, dot))); + t.first_para_continues = true; + assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list"); + assert_eq!(t.typed_list_at(10), Some((8, dot))); +} + #[test] fn character_controls_reject_collapsed_and_nonfinite_transforms() { let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default()); diff --git a/crates/doc/src/twist.rs b/crates/doc/src/twist.rs new file mode 100644 index 000000000..8865fd2a9 --- /dev/null +++ b/crates/doc/src/twist.rs @@ -0,0 +1,231 @@ +//! The Twist effect's angle in saved documents. A positive angle turns clockwise on the page in +//! VectorCraft 0.21.0 and later; earlier releases turned it counterclockwise. Documents of version +//! 2 ([`TWIST_CLOCKWISE_SINCE`]) turn clockwise. Version 1 documents written before 0.21.0 have +//! their Twist angles negated when they open, so they draw as they were saved +//! ([`Document::migrate_twist_angles`], [`written_clockwise`]), and a save for an earlier app +//! negates them back ([`Document::store_legacy_twist_angles`]). Graphic style library files +//! (`.vcstyles`) of version 1 have their Twist angles negated when they are read +//! ([`crate::style_libs::read`]); [`crate::style_libs::write`] writes version 2. + +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use crate::style_libs::StyleLibrary; +use crate::{Appearance, Document, Effect, Node}; + +/// The Twist effect's id. +pub const TWIST: &str = "distort.twist"; + +/// The first document version whose Twist angles turn clockwise for a positive value. +pub const TWIST_CLOCKWISE_SINCE: u32 = 2; + +/// The first release (major, minor) whose Twist turned clockwise for a positive angle. +const CLOCKWISE_RELEASE: (u64, u64) = (0, 21); + +/// Did the app named by a native file's `generator` (`"VectorCraft 0.21.0"`) turn Twist effects +/// clockwise for a positive angle? It did from 0.21.0. Any other value, or none, is an earlier +/// app (DrawCraft included). +pub fn written_clockwise(generator: Option<&str>) -> bool { + let Some(version) = generator.and_then(|g| g.strip_prefix("VectorCraft ")) else { return false }; + let mut parts = version.split('.').map(|p| p.parse::().ok()); + match (parts.next().flatten(), parts.next().flatten()) { + (Some(major), Some(minor)) => (major, minor) >= CLOCKWISE_RELEASE, + _ => false, + } +} + +/// The angle a Twist effect without a readable one draws with (`vectorcraft-effects` reads a +/// missing or unreadable `angle` as this). +const DEFAULT_ANGLE: f64 = 10.0; + +/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s +/// `DOC_KEY`), whose art can carry effects. +const BRUSHES_KEY: &str = "brushes"; + +/// The angle a Twist effect with `params` draws with, read as `vectorcraft-effects` reads it: a +/// number, the number a string starts with, or a boolean as 1 or 0; anything else (or a value +/// that is not finite) is [`DEFAULT_ANGLE`]. +fn angle(params: &Value) -> f64 { + let v = match params.get("angle") { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => { + let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect(); + t.parse().ok() + } + Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }), + _ => None, + }; + v.filter(|v| v.is_finite()).unwrap_or(DEFAULT_ANGLE) +} + +/// Write the negated angle a Twist effect with `params` draws with into `params`, as a number. +fn negate_params(params: &mut Value) { + // `0.0 - a` rather than `-a`: an angle of 0 stays 0, not -0. + let turned = Value::from(0.0 - angle(params)); + match params { + Value::Object(m) => { + m.insert("angle".into(), turned); + } + p => *p = json!({ "angle": turned }), + } +} + +fn has_twist(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.id == TWIST) +} + +fn appearance_has_twist(a: &Appearance) -> bool { + has_twist(&a.effects) || a.items.iter().any(|i| has_twist(i.effects())) +} + +fn negate_appearance(a: &mut Appearance) { + for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + if e.id == TWIST { + negate_params(&mut e.params); + } + } +} + +/// The appearances a Shaper Group's face and edge edits paint with. +fn shaper_appearances(n: &Node) -> impl Iterator { + let s = n.extras().shaper.as_deref(); + let faces = s.into_iter().flat_map(|s| s.faces.iter().filter_map(|f| f.paint.as_ref())); + let edges = s.into_iter().flat_map(|s| s.edges.iter().filter_map(|e| e.paint.as_ref())); + faces.chain(edges) +} + +/// Does `n`'s subtree (opacity-mask art and Shaper edits included) hold a Twist effect? +fn node_has_twist(n: &Node) -> bool { + appearance_has_twist(&n.appearance) + || n.extras().mask.as_ref().is_some_and(|m| node_has_twist(&m.art)) + || shaper_appearances(n).any(appearance_has_twist) + || n.children().is_some_and(|c| c.iter().any(|c| node_has_twist(c))) +} + +/// Negate the Twist angles of `a`'s subtree, copying only the nodes on the way to them. +fn negate_node(a: &mut Arc) { + if !node_has_twist(a) { + return; + } + let n = Arc::make_mut(a); + if appearance_has_twist(&n.appearance) { + negate_appearance(&mut n.appearance); + } + if let Some(x) = n.extras_if_any() { + if let Some(m) = x.mask.as_mut() { + negate_node(&mut m.art); + } + if let Some(s) = x.shaper.as_mut() { + let faces = s.faces.iter_mut().filter_map(|f| f.paint.as_mut()); + for p in faces.chain(s.edges.iter_mut().filter_map(|e| e.paint.as_mut())) { + negate_appearance(p); + } + } + } + for c in n.children_mut().into_iter().flatten() { + negate_node(c); + } +} + +fn is_twist_json(m: &Map) -> bool { + m.get("id").and_then(Value::as_str) == Some(TWIST) +} + +/// Does JSON `v` (brush definitions) hold a Twist effect? +fn json_has_twist(v: &Value) -> bool { + match v { + Value::Object(m) => is_twist_json(m) || m.values().any(json_has_twist), + Value::Array(a) => a.iter().any(json_has_twist), + _ => false, + } +} + +/// Negate the Twist angles in JSON `v`: every object whose `id` is [`TWIST`] is a serialized +/// [`Effect`] (object ids are numbers). +fn negate_json(v: &mut Value) { + match v { + Value::Object(m) if is_twist_json(m) => negate_params(m.entry("params").or_insert(Value::Null)), + Value::Object(m) => m.values_mut().for_each(negate_json), + Value::Array(a) => a.iter_mut().for_each(negate_json), + _ => {} + } +} + +/// Negate every Twist angle of a graphic style library: its styles' and its patterns' art. +pub(crate) fn negate_library(lib: &mut StyleLibrary) { + for g in &mut lib.styles { + negate_appearance(&mut g.appearance); + } + for p in &mut lib.patterns { + p.art.iter_mut().for_each(negate_node); + } +} + +impl Document { + /// A document before version [`TWIST_CLOCKWISE_SINCE`] that holds a Twist effect: when + /// `written_clockwise` (asked only then; see [`written_clockwise`]) says the app that saved it + /// turned Twist counterclockwise, negate its Twist angles, so it draws as it was saved. A later + /// version is left as it is, and the document's version is not changed. It recurses through + /// the art, once per level of nesting. + pub fn migrate_twist_angles(&mut self, written_clockwise: impl FnOnce() -> bool) { + if self.version >= TWIST_CLOCKWISE_SINCE || !self.has_twist() || written_clockwise() { + return; + } + self.negate_twist_angles(); + } + + /// The reverse of [`Document::migrate_twist_angles`], for saving for an app that turned Twist + /// counterclockwise: negate the Twist angles and mark the document version 1. Call it on a + /// document whose angles turn clockwise, as every opened or new document's do, whatever + /// version another conversion for that save has marked it already. + pub fn store_legacy_twist_angles(&mut self) { + self.version = TWIST_CLOCKWISE_SINCE - 1; + if self.has_twist() { + self.negate_twist_angles(); + } + } + + /// Does any Twist effect sit where [`Document::negate_twist_angles`] negates them? + fn has_twist(&self) -> bool { + let mut roots = self + .layers + .iter() + .chain(self.symbols.iter().map(|s| &s.art)) + .chain(self.graph_designs.iter().map(|g| &g.art)) + .chain(self.patterns.iter().flat_map(|p| p.art.iter())) + .chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter())); + roots.any(|r| node_has_twist(r)) + || self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) + || self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist) + } + + /// Negate every Twist angle: in the art of the layers, symbols, graph designs, pattern + /// swatches and the pattern definition that pattern editing's Cancel puts back (opacity-mask + /// art and Shaper edits included), in the graphic styles and in the brush definitions. + fn negate_twist_angles(&mut self) { + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + negate_node(root); + } + if self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) { + for g in self.graphic_styles.iter_mut() { + negate_appearance(&mut g.appearance); + } + } + if self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist) + && let Some(b) = self.unknown.get_mut(BRUSHES_KEY) + { + negate_json(b); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/doc/src/twist/tests.rs b/crates/doc/src/twist/tests.rs new file mode 100644 index 000000000..699be66cb --- /dev/null +++ b/crates/doc/src/twist/tests.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Rect, shapes}; + +use super::*; +use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec}; +use crate::{GraphicStyle, OpacityMask, PatternDef, Symbol}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// An appearance with a Twist of `params` on the object and on its fill, and a Roughen. +fn twisted_appearance(params: &Value) -> Appearance { + let mut a = Appearance::default_art(); + a.effects.push(fx(TWIST, params.clone())); + a.effects.push(fx("distort.roughen", json!({"size": 5}))); + a.items[0].effects_mut().push(fx(TWIST, params.clone())); + a +} + +fn twisted(d: &mut Document, params: &Value) -> Node { + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), twisted_appearance(params)) +} + +/// A version 1 document with Twist effects of `params` everywhere one is stored: on a path, in a +/// group, in an opacity mask's art, in a Shaper edit, in a symbol, a pattern swatch and a graphic +/// style, and `brush` in a brush definition. Its first layer holds the art; its last has no +/// Twist. +fn document(params: &Value, brush: &Value) -> Document { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let path = twisted(&mut d, params); + d.insert(Some(l), 0, path).unwrap(); + let inner = twisted(&mut d, params); + let group = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.insert(Some(l), 1, group).unwrap(); + let mask_art = twisted(&mut d, params); + let mut masked = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art()); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, true))); + d.insert(Some(l), 2, masked).unwrap(); + let mut shaper = Node::group(d.alloc_id(), vec![]); + let face = FaceEdit { + selector: FaceSelector { sources: vec![0], anchor: Point::ZERO }, + erase: false, + erase_edges: false, + paint_source: None, + merge: None, + paint: Some(twisted_appearance(params)), + }; + shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] })); + d.insert(Some(l), 3, shaper).unwrap(); + let art = twisted(&mut d, params); + d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art), dynamic: false }); + let art = twisted(&mut d, params); + d.patterns.push(PatternDef::new("P", vec![Arc::new(art)])); + d.graphic_styles.push(GraphicStyle::new("G", twisted_appearance(params))); + d.unknown.insert(BRUSHES_KEY.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [brush]}}}])); + d.add_layer(None); + d.version = 1; + d +} + +/// The angle each Twist effect of the saved document draws with, in the order the JSON lists them. +fn angles(d: &Document) -> Vec { + fn walk(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => { + if is_twist_json(m) { + out.push(angle(m.get("params").unwrap_or(&Value::Null))); + } + m.values().for_each(|c| walk(c, out)); + } + Value::Array(a) => a.iter().for_each(|c| walk(c, out)), + _ => {} + } + } + let mut out = vec![]; + walk(&serde_json::to_value(d).unwrap(), &mut out); + out +} + +#[test] +fn a_version_1_document_from_before_0_21_has_its_twist_angles_negated() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST})); + let before = angles(&d); + // Two per path, shaper edit and style; one in the brush. + assert_eq!(before.len(), 15, "{before:?}"); + assert_eq!(before.iter().filter(|&&a| a == DEFAULT_ANGLE).count(), 1, "{before:?}"); + d.migrate_twist_angles(|| false); + assert_eq!(d.version, 1, "the caller sets the version"); + assert_eq!(angles(&d), before.iter().map(|a| -a).collect::>()); + let roughen = serde_json::to_string(&d).unwrap().matches(r#""id":"distort.roughen","params":{"size":5}"#).count(); + assert_eq!(roughen, 7, "other effects are left alone"); +} + +#[test] +fn a_document_that_turned_clockwise_is_left_as_it_is() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST, "params": {"angle": 30}})); + let before = d.clone(); + d.migrate_twist_angles(|| true); + assert_eq!(d, before, "version 1, written by 0.21.0 or later"); + d.version = 2; + let before = d.clone(); + d.migrate_twist_angles(|| panic!("a version 2 document's writer is not asked about")); + assert_eq!(d, before, "version 2"); +} + +#[test] +fn the_writer_is_asked_about_only_when_a_twist_is_stored() { + let mut d = Document::new(10.0, 10.0); + d.version = 1; + let before = d.clone(); + d.migrate_twist_angles(|| panic!("a document without a Twist needs no answer")); + assert_eq!(d, before); +} + +#[test] +fn angles_are_read_as_the_effect_reads_them() { + let read = |params: Value| { + let mut e = fx(TWIST, params); + negate_params(&mut e.params); + e.params + }; + assert_eq!(read(json!({"angle": 30, "x": 1})), json!({"angle": -30.0, "x": 1})); + assert_eq!(read(json!({"angle": "45 deg"})), json!({"angle": -45.0})); + assert_eq!(read(json!({"angle": true})), json!({"angle": -1.0})); + assert_eq!(read(json!({})), json!({"angle": -10.0})); + assert_eq!(read(Value::Null), json!({"angle": -10.0})); + let zero = read(json!({"angle": 0})); + assert!(zero["angle"].as_f64().is_some_and(|a| a == 0.0 && a.is_sign_positive()), "{zero}"); +} + +#[test] +fn only_the_art_holding_a_twist_is_copied() { + let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST})); + let plain = d.layers.last().cloned().unwrap(); + let twisted = d.layers[0].clone(); + d.migrate_twist_angles(|| false); + assert!(Arc::ptr_eq(d.layers.last().unwrap(), &plain), "a layer without a Twist is not copied"); + assert!(!Arc::ptr_eq(&d.layers[0], &twisted)); +} + +#[test] +fn storing_for_an_earlier_app_and_reading_back_round_trips() { + let original = document(&json!({"angle": 30.0}), &json!({"id": TWIST, "params": {"angle": -12.5}})); + let mut d = original.clone(); + d.migrate_twist_angles(|| false); + d.version = 2; + let current = d.clone(); + assert_eq!(angles(¤t), angles(&original).iter().map(|a| -a).collect::>()); + d.store_legacy_twist_angles(); + assert_eq!(d, original); + d.migrate_twist_angles(|| false); + d.version = 2; + assert_eq!(d, current); + // Another conversion for the same save may have marked the document version 1 already. + d.version = 1; + d.store_legacy_twist_angles(); + assert_eq!(d, original); +} + +#[test] +fn a_version_1_style_library_has_its_twist_angles_negated() { + let lib = StyleLibrary { name: "L".into(), styles: vec![GraphicStyle::new("G", twisted_appearance(&json!({"angle": 30.0})))], patterns: vec![] }; + let text = crate::style_libs::write(&lib); + assert!(text.contains(r#""version": 2"#), "{text}"); + assert_eq!(crate::style_libs::read(&text, "x").unwrap(), lib, "a version 2 file reads as written"); + let old = crate::style_libs::read(&text.replacen(r#""version": 2"#, r#""version": 1"#, 1), "x").unwrap(); + let fx = &old.styles[0].appearance.effects; + assert_eq!(fx[0].params, json!({"angle": -30.0})); + assert_eq!(fx[1], lib.styles[0].appearance.effects[1]); + assert_eq!(old.styles[0].appearance.items[0].effects()[0].params, json!({"angle": -30.0})); +} + +#[test] +fn releases_from_0_21_turned_twist_clockwise() { + for g in ["VectorCraft 0.21.0", "VectorCraft 0.21.4", "VectorCraft 0.22.0-dev", "VectorCraft 1.0.0"] { + assert!(written_clockwise(Some(g)), "{g}"); + } + for g in ["VectorCraft 0.20.0", "VectorCraft 0.9.12", "DrawCraft 0.1.0", "VectorCraft", "VectorCraft x.y", "vectorcraft 0.21.0"] { + assert!(!written_clockwise(Some(g)), "{g}"); + } + assert!(!written_clockwise(None)); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 00c587eab..313476c01 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -77,7 +77,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index f8590bbb2..a1d8df2d6 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -366,7 +366,7 @@ pub fn blend_mode(s: &str) -> BlendMode { } /// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object). -pub(crate) fn color_param(p: &Value, default: Color) -> Color { +pub fn color_param(p: &Value, default: Color) -> Color { match p.get("color") { Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default), Some(Value::Array(a)) if a.len() >= 3 => { diff --git a/crates/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 87eca97d7..324fddfac 100644 --- a/crates/effects/src/stroke/width.rs +++ b/crates/effects/src/stroke/width.rs @@ -114,13 +114,16 @@ pub(super) fn split_at(pts: &[Point], corner: &[bool], closed: bool, marks: &[f6 let seg_count = if closed { n } else { n.saturating_sub(1) }; let mut out = (Vec::with_capacity(n + marks.len()), Vec::with_capacity(n + marks.len())); let mut acc = 0.0; + // Sorted once, so each segment finds its marks by search, not by a pass over all of them. + let mut marks = marks.to_vec(); + marks.sort_by(f64::total_cmp); for i in 0..seg_count { let (a, b) = (pts[i], pts[(i + 1) % n]); let len = a.distance(b); out.0.push(a); out.1.push(corner[i]); - let mut inner: Vec = marks.iter().copied().filter(|d| *d > acc + 1e-9 && *d < acc + len - 1e-9).collect(); - inner.sort_by(f64::total_cmp); + let from = marks.partition_point(|d| *d <= acc + 1e-9); + let mut inner: Vec = marks.iter().skip(from).copied().take_while(|d| *d < acc + len - 1e-9).collect(); inner.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); for d in inner { out.0.push(a.lerp(b, (d - acc) / len)); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 87e2d48be..6789a2116 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -61,6 +61,8 @@ vectorcraft-testkit = { workspace = true } proptest = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } +# How many times an open reads a PDF. +vectorcraft-pdf = { workspace = true, features = ["read-count"] } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } diff --git a/crates/engine/examples/anchor_bench.rs b/crates/engine/examples/anchor_bench.rs new file mode 100644 index 000000000..877526b10 --- /dev/null +++ b/crates/engine/examples/anchor_bench.rs @@ -0,0 +1,320 @@ +//! `cargo run --release -p vectorcraft-engine --example anchor_bench` prints the wall time of the +//! engine steps of editing anchors with Direct Selection, on three synthetic documents: one closed path of +//! 50,000 anchors, 2,000 closed paths of 50 anchors each, and one open path of 200,000 anchors. +//! `ITERS` sets the runs per step (default 7); each row is the median in ms. +// Example (dev tool): unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::hint::black_box; +use std::time::Instant; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind}; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, SubPath, Vec2}; +use vectorcraft_render::{RenderOptions, Renderer}; + +/// Anchors are 10 pt apart and their handles 3 pt long, along the path. +const STEP: f64 = 10.0; +const HANDLE: f64 = 3.0; + +/// A test document: its paths, an anchor to hover, click and drag, a point on a segment, and a +/// marquee from an empty point over about 10,000 anchors. +struct Shape { + name: &'static str, + doc: Document, + ids: Vec, + anchor: (NodeId, usize), + segment: Point, + marquee: (Point, Point), +} + +/// A closed path of `n` smooth anchors around `center`. +fn circle(center: Point, n: usize) -> PathData { + let r = n as f64 * STEP / std::f64::consts::TAU; + let anchors = (0..n) + .map(|i| { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + let p = center + Vec2::new(r * t.cos(), r * t.sin()); + Anchor::smooth(p, p + Vec2::new(-t.sin(), t.cos()) * HANDLE) + }) + .collect(); + PathData::single(SubPath::new(anchors, true)) +} + +fn add(doc: &mut Document, path: PathData) -> NodeId { + let l = doc.layers.first().map(|l| l.id); + let id = doc.alloc_id(); + doc.insert(l, usize::MAX, Node::path(id, path, Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0))).unwrap(); + id +} + +fn one_outline() -> Shape { + let n = 50_000; + let mut doc = Document::new(1600.0, 1200.0); + let id = add(&mut doc, circle(Point::ZERO, n)); + let r = n as f64 * STEP / std::f64::consts::TAU; + let a = 72f64.to_radians(); + let mid = (n / 2) as f64 / n as f64 * std::f64::consts::TAU; + let next = (n / 2 + 1) as f64 / n as f64 * std::f64::consts::TAU; + let segment = Point::new(r * (mid.cos() + next.cos()) / 2.0, r * (mid.sin() + next.sin()) / 2.0); + Shape { + name: "one closed path, 50,000 anchors", + doc, + ids: vec![id], + anchor: (id, n / 2), + segment, + marquee: (Point::new(r + 50.0, -5.0), Point::new(r * a.cos() - 1.0, r * a.sin() + 1.0)), + } +} + +fn many_paths() -> Shape { + let mut doc = Document::new(1600.0, 1200.0); + let ids: Vec = + (0..2000).map(|i| add(&mut doc, circle(Point::new((i % 50) as f64 * 200.0 + 100.0, (i / 50) as f64 * 200.0 + 100.0), 50))).collect(); + let r = 50.0 * STEP / std::f64::consts::TAU; + // Path 1,025 (row 20, column 25), its anchors 0 and 1. + let c = Point::new(25.0 * 200.0 + 100.0, 20.0 * 200.0 + 100.0); + let t = std::f64::consts::TAU / 50.0; + let segment = c + Vec2::new(r * (1.0 + t.cos()) / 2.0, r * t.sin() / 2.0); + Shape { + name: "2,000 closed paths, 50 anchors each", + doc, + anchor: (ids[1025], 0), + ids, + segment, + marquee: (Point::ZERO, Point::new(10_000.0, 800.0)), + } +} + +fn long_open_path() -> Shape { + let n = 200_000; + let mut doc = Document::new(1600.0, 1200.0); + let anchors = (0..n) + .map(|i| { + let p = Point::new(i as f64 * STEP, 0.0); + Anchor::smooth(p, p + Vec2::new(HANDLE, 0.0)) + }) + .collect(); + let id = add(&mut doc, PathData::single(SubPath::new(anchors, false))); + Shape { + name: "one open path, 200,000 anchors", + doc, + ids: vec![id], + anchor: (id, n / 2), + segment: Point::new((n / 2) as f64 * STEP + STEP / 2.0, 0.0), + marquee: (Point::new(-5.0, -20.0), Point::new(99_995.0, 30.0)), + } +} + +fn iters() -> usize { + std::env::var("ITERS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1) +} + +fn median(mut ms: Vec) -> f64 { + ms.sort_by(f64::total_cmp); + ms[ms.len() / 2] +} + +fn time(mut f: impl FnMut()) -> f64 { + median( + (0..iters()) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ) +} + +fn ev(kind: PointerKind, p: Point) -> PointerEvent { + PointerEvent::new(kind, p.x, p.y) +} + +fn exec(s: &mut Session, cmd: &str, p: Value) { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); +} + +fn anchor_at(s: &Session, (id, ai): (NodeId, usize)) -> Anchor { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai] +} + +/// The anchors selected: those direct-selected, and every anchor of a path selected whole. +fn selected_anchors(s: &Session) -> usize { + let st = s.doc().unwrap(); + let count = |id: &NodeId| st.doc.node(*id).and_then(|n| n.path_data()).map_or(0, |pd| pd.anchors().count()); + st.selection.objects.iter().map(|id| st.selection.partial(*id).map_or_else(|| count(id), |set| set.len())).sum() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn row(name: &str, ms: f64) { + println!(" {name:<58} {ms:>10.3} ms"); +} + +/// Select `count` anchors: the first ones of the first path, or the first anchors of as many +/// paths as needed. +fn select_count(s: &mut Session, shape: &Shape, count: usize) { + exec(s, "select.none", json!({})); + let mut left = count; + for id in &shape.ids { + if left == 0 { + break; + } + let n = s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len(); + let take = left.min(n); + let refs: Vec<[usize; 2]> = (0..take).map(|i| [0, i]).collect(); + exec(s, "select.anchors", json!({"id": id.0, "anchors": refs, "mode": "add"})); + left -= take; + } +} + +fn bench(shape: Shape) { + println!("{}", shape.name); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + let mut s = Session::new(); + s.add_document(shape.doc.clone(), None); + s.select_tool("directSelection", view).unwrap(); + let a = anchor_at(&s, shape.anchor).p; + let none = Mods::default(); + { + let doc = s.doc().unwrap().doc.clone(); + let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0, clip_outlines: true, ..Default::default() }; + row("hit test at an anchor", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, a, opt))))); + row("hit test at a segment", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment, opt))))); + row("hit test beside the path", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment + Vec2::new(0.0, 40.0), opt))))); + } + + // Hover with nothing selected, then with one anchor of the path selected. + for selected in [false, true] { + if selected { + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + } + let what = if selected { "one anchor selected" } else { "nothing selected" }; + row(&format!("hover over an anchor, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, a), view).unwrap()))); + row(&format!("cursor over an anchor, {what}"), time(|| _ = black_box(s.cursor(a, none, view)))); + row(&format!("hover over a segment, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, shape.segment), view).unwrap()))); + row(&format!("cursor over a segment, {what}"), time(|| _ = black_box(s.cursor(shape.segment, none, view)))); + } + + // A click that selects one anchor. + exec(&mut s, "select.none", json!({})); + row( + "click that selects one anchor", + time(|| { + s.pointer(&ev(PointerKind::Down, a), view).unwrap(); + s.pointer(&ev(PointerKind::Up, a), view).unwrap(); + }), + ); + assert_eq!(selected_anchors(&s), 1, "the click selects one anchor"); + + // One anchor dragged: each pointer move, then the release. + let undo = undo_len(&s); + s.pointer(&ev(PointerKind::Down, a), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, 20.0)), view).unwrap(); + let mut y = 20.0; + row( + "anchor drag, one pointer move", + time(|| { + y += 1.0; + s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, y)), view).unwrap(); + }), + ); + let t = Instant::now(); + s.pointer(&ev(PointerKind::Up, a + Vec2::new(0.0, y)), view).unwrap(); + row("anchor drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(undo_len(&s), undo + 1, "the anchor drag records one step"); + exec(&mut s, "edit.undo", json!({})); + + // The selected anchor's outgoing handle dragged. + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let h = anchor_at(&s, shape.anchor).h_out; + let undo = undo_len(&s); + s.pointer(&ev(PointerKind::Down, h), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, 20.0)), view).unwrap(); + let mut y = 20.0; + row( + "handle drag, one pointer move", + time(|| { + y += 1.0; + s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, y)), view).unwrap(); + }), + ); + let t = Instant::now(); + s.pointer(&ev(PointerKind::Up, h + Vec2::new(0.0, y)), view).unwrap(); + row("handle drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(undo_len(&s), undo + 1, "the handle drag records one step"); + assert_eq!(anchor_at(&s, shape.anchor).p, a, "the handle drag leaves the anchor in place"); + exec(&mut s, "edit.undo", json!({})); + + // A marquee over about 10,000 anchors. + let (m0, m1) = shape.marquee; + let ms = time(|| { + exec(&mut s, "select.none", json!({})); + s.pointer(&ev(PointerKind::Down, m0), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, m0.lerp(m1, 0.5)), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, m1), view).unwrap(); + s.pointer(&ev(PointerKind::Up, m1), view).unwrap(); + }); + row(&format!("marquee over {} anchors (press to release)", selected_anchors(&s)), ms); + + // Select All with Direct Selection. + row("Select All", time(|| exec(&mut s, "select.all", json!({})))); + row("tool overlays, everything selected", time(|| drop(s.overlays(view)))); + + // Nudges of 1, 1,000 and every anchor, and Undo and Redo of each. + let total: usize = shape.ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len()).sum(); + for count in [1, 1000, total] { + select_count(&mut s, &shape, count); + row(&format!("nudge of {count} anchors"), time(|| exec(&mut s, "path.moveAnchors", json!({"dx": 1, "dy": 0})))); + row(&format!("undo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.undo", json!({})))); + row(&format!("redo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.redo", json!({})))); + } + + // The render of the document after one anchor moves (the renderer warm). + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let at = Affine::translate(Vec2::new(800.0 - a.x, 600.0 - a.y)); + r.render(&s.doc().unwrap().doc, 1600, 1200, at, &opts); + let ms = median( + (0..iters()) + .map(|_| { + exec(&mut s, "path.moveAnchors", json!({"dx": 0, "dy": 1})); + let doc = s.doc().unwrap().doc.clone(); + let t = Instant::now(); + drop(r.render(&doc, 1600, 1200, at, &opts)); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ); + row("render 1600x1200 around the anchor after it moves", ms); + let doc = s.doc().unwrap().doc.clone(); + row("render 1600x1200 around the anchor, nothing changed", time(|| drop(r.render(&doc, 1600, 1200, at, &opts)))); + + // Delete of one anchor, undone after each run. + let ms = median( + (0..iters()) + .map(|_| { + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let t = Instant::now(); + exec(&mut s, "edit.clear", json!({})); + let ms = t.elapsed().as_secs_f64() * 1000.0; + exec(&mut s, "edit.undo", json!({})); + ms + }) + .collect(), + ); + row("Delete of one anchor", ms); +} + +fn main() { + bench(one_outline()); + bench(many_paths()); + bench(long_open_path()); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 1af3693ca..b1b9efd60 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -168,9 +168,9 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } } -/// Objects are selected, or the Artboard tool is chosen (Copy and Cut take its artboard). -pub fn has_selection_or_artboard_tool(s: &Session) -> std::result::Result<(), String> { - if s.tool_id() == "artboard" { has_doc(s) } else { has_selection(s) } +/// Objects or ruler guides are selected, or the Artboard tool is chosen (what Copy and Cut take). +pub fn has_selection_guides_or_artboard_tool(s: &Session) -> std::result::Result<(), String> { + if s.tool_id() == "artboard" { has_doc(s) } else { has_selection_or_guides(s) } } /// Objects or ruler guides are selected (what Delete and the arrow keys act on). pub fn has_selection_or_guides(s: &Session) -> std::result::Result<(), String> { @@ -484,9 +484,10 @@ pub fn matrix_param(p: &Value, key: &str) -> Option { } /// A colour from `"#rrggbb"`, a colour key (`"cmyk 0 100 100 0"`, `"lab 55 60 40"`), `[r,g,b]` (0..1), /// `{"c":..,"m":..,"y":..,"k":..}` (percent when any component is above 1, else 0..1), `{"gray":..}` or `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about -/// −128..127). +/// −128..127). `None` for a hex color with an alpha part (`#rrggbbaa` or `#rgba`, with or without the `#`). pub fn color_value(v: &Value) -> Option { match v { + Value::String(s) if hex_with_alpha(s) => None, // A hex, or a colour key ("cmyk 0 100 100 0", as Recolor Artwork lists colours). Value::String(s) => Color::from_hex(s).or_else(|| vectorcraft_color::recolor::ColorKey::parse(s).map(|k| k.color())), Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)), @@ -512,6 +513,23 @@ pub fn color_value(v: &Value) -> Option { } } +/// Is `s` a hex color with an alpha part: eight or four hex digits, with or without the `#`? +/// [`Color::from_hex`] reads `#rrggbbaa` as `#rrggbb`, without the alpha. +pub(crate) fn hex_with_alpha(s: &str) -> bool { + let s = s.trim(); + let h = s.strip_prefix('#').unwrap_or(s); + matches!(h.len(), 4 | 8) && h.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// The color `p` gives for `key`, read by [`color_value`]: `None` when `p` leaves it out or gives +/// null. Any other value that is not a color is an error: "bad color {v}". +pub(crate) fn color_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => color_value(v).map(Some).ok_or_else(|| bad(cmd, format!("bad color {v}"))), + } +} + /// `v` as an object id: an error unless it is a non-negative integer. fn object_id(v: &Value) -> Result { v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}"))) diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 02f886442..ac9597cbd 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -7,6 +7,7 @@ mod color; mod document; mod pathtools; mod pen; +mod select; mod typetools; use super::CommandSpec; @@ -19,6 +20,7 @@ pub fn specs() -> Vec { v.extend(document::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); + v.extend(select::specs()); v.extend(typetools::specs()); v } diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index d1ed6c84a..ac8947cb4 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Find Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}", + "{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up, #rrggbbaa and #rgba refused; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}", has_doc, nearest::find ), diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index 30548b80a..52f7f3168 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -1,9 +1,11 @@ //! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of //! the document named with the date and time through File › Save a Copy's save; New Symbols from //! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a -//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins › -//! Document Tools) is the UI's. +//! copy of every open document to one folder as Save a Copy does (#4920); Batch Trace Folder traces +//! each image of a folder and saves the results to another ([`batch_trace`], #4921). Its submenu +//! (Object › Plug-ins › Document Tools) is the UI's. +mod batch_trace; #[cfg(test)] mod tests; @@ -14,7 +16,7 @@ use vectorcraft_doc::{Node, NodeId, NodeKind, SymbolRegistration}; use super::super::brushsym::{instance_in_place, normalize, place_xf, store_symbol}; use super::super::fileio::{self, SaveMode}; -use super::super::{CommandSpec, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or}; +use super::super::{CommandSpec, always, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or}; use crate::{Result, Session}; /// The most numbers (`-2`, `-3`…) tried after a timestamped name that is taken. @@ -40,6 +42,15 @@ pub fn specs() -> Vec { has_doc, export_all_open ), + cmd!( + "addon.document.batchTraceFolder", + "Batch Trace Folder", + ["Object", "Plug-ins", "Document Tools"], + None, + "{folder, destinationFolder, preset?: name (imageTrace.presets; default: Default), expand?: false (true: trace and expand, as Make and Expand), format?: vectorcraft (default)|svg|pdf|ai|eps, includeSubdirs?: false} open each image in folder (png, jpg, tif, bmp, gif, webp, psd), select it, trace it with the preset, save it into destinationFolder as . (-2, -3… added when that name is taken, so no file is replaced) and close it again, whether it traced or not; the document that was active stays active. Caps: 500 images, 8 folder levels; a folder with more than 1,000 entries is refused. Not on the web (no file system) → {traced: [{path, output}], failed: [{path, error}], format} (#4921)", + always, + batch_trace::batch_trace_folder + ), cmd!( "addon.document.symbolsFromLayers", "New Symbols from Layers", diff --git a/crates/engine/src/cmd/addons/document/batch_trace.rs b/crates/engine/src/cmd/addons/document/batch_trace.rs new file mode 100644 index 000000000..0340fca18 --- /dev/null +++ b/crates/engine/src/cmd/addons/document/batch_trace.rs @@ -0,0 +1,127 @@ +//! Batch Trace Folder (`addon.document.batchTraceFolder`, #4921): each image in a folder is opened, +//! traced with a preset (and expanded when asked), saved into a destination folder under its own +//! name, and closed again, whether it traced or not. + +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; + +#[cfg(not(target_arch = "wasm32"))] +use super::super::super::fileio; +use super::super::super::{Result, Session, bad}; +#[cfg(not(target_arch = "wasm32"))] +use super::super::super::{bool_or, str_param}; + +/// The command id. +pub(super) const ID: &str = "addon.document.batchTraceFolder"; + +#[cfg(target_arch = "wasm32")] +pub(super) fn batch_trace_folder(_: &mut Session, _: &Value) -> Result { + Err(bad(ID, "no file system here")) +} + +#[cfg(not(target_arch = "wasm32"))] +/// The image files a batch traces, by extension. +const IMAGE_EXTS: [&str; 10] = ["png", "jpg", "jpeg", "tif", "tiff", "bmp", "gif", "webp", "psd", "psb"]; +#[cfg(not(target_arch = "wasm32"))] +/// Caps on a batch (folders and MCP payloads are untrusted). +pub(super) const MAX_FILES: usize = 500; +#[cfg(not(target_arch = "wasm32"))] +const MAX_DEPTH: usize = 8; +#[cfg(not(target_arch = "wasm32"))] +const FORMATS: [&str; 5] = ["vectorcraft", "svg", "pdf", "ai", "eps"]; + +#[cfg(not(target_arch = "wasm32"))] +/// The image files in `folder` (and its subfolders with `deep`), sorted. +fn images(folder: &str, deep: bool, depth: usize, out: &mut Vec) -> Result<()> { + if depth > MAX_DEPTH { + return Err(bad(ID, format!("folders deeper than {MAX_DEPTH} levels"))); + } + let max = MAX_FILES.saturating_mul(2); + let (entries, truncated) = fileio::list_dir(folder, max)?; + // The listing is cut before it is sorted, so a cut one could skip any image: refuse it. + if truncated { + return Err(bad(ID, format!("{folder}: more than {max} files and folders"))); + } + let mut subdirs = vec![]; + for e in entries { + let name = e.get("name").and_then(Value::as_str).unwrap_or(""); + if name.is_empty() || name.starts_with('.') { + continue; + } + let path = std::path::Path::new(folder).join(name).to_string_lossy().into_owned(); + match e.get("kind").and_then(Value::as_str) { + Some("file") if IMAGE_EXTS.contains(&fileio::extension(&path).as_str()) => out.push(path), + Some("folder") if deep => subdirs.push(path), + _ => {} + } + if out.len() > MAX_FILES { + return Err(bad(ID, format!("more than {MAX_FILES} images"))); + } + } + for d in subdirs { + images(&d, deep, depth + 1, out)?; + } + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn batch_trace_folder(s: &mut Session, p: &Value) -> Result { + let folder = str_param(p, "folder").map(fileio::absolute_path).ok_or_else(|| bad(ID, "missing `folder`"))?; + let dest = str_param(p, "destinationFolder").map(fileio::absolute_path).ok_or_else(|| bad(ID, "missing `destinationFolder`"))?; + let format = str_param(p, "format").unwrap_or("vectorcraft"); + if !FORMATS.contains(&format) { + return Err(bad(ID, format!("`format` must be one of {}", FORMATS.join(", ")))); + } + let preset = str_param(p, "preset").unwrap_or("Default").to_string(); + let presets = s.execute("imageTrace.presets", &json!({}))?; + if !presets["presets"].as_array().into_iter().flatten().any(|q| q["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&preset))) { + return Err(bad(ID, format!("no tracing preset `{preset}` (see imageTrace.presets)"))); + } + let trace = if bool_or(p, "expand", false) { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; + fileio::may_read(&folder)?; + fileio::may_write(&dest)?; + let mut files = vec![]; + images(&folder, bool_or(p, "includeSubdirs", false), 0, &mut files)?; + files.sort(); + if files.is_empty() { + return Err(bad(ID, "no images in the folder")); + } + std::fs::create_dir_all(&dest).map_err(|e| bad(ID, format!("{dest}: {e}")))?; + let ext = fileio::save_format(Some(format), None).map_err(|e| bad(ID, e))?.extensions.first().copied().unwrap_or(format); + let active = s.active_index(); + let (mut traced, mut failed, mut taken) = (vec![], vec![], Vec::::new()); + for path in &files { + let opened_before = s.documents().len(); + let one = |s: &mut Session| -> Result { + s.execute("document.open", &json!({ "path": path }))?; + s.execute("select.all", &json!({}))?; + s.execute(trace, &json!({ "preset": preset }))?; + let stem = fileio::file_stem(path); + // Names this batch already took count as taken, as do files on disk. + let free = super::free_name(&dest, &stem, ext, |o| taken.iter().any(|t| t == o) || std::path::Path::new(o).exists()) + .ok_or_else(|| bad(ID, "every numbered name for the result is taken"))?; + s.execute("document.save", &json!({ "path": free, "format": format }))?; + Ok(free) + }; + let result = one(s); + // The image's document closes whatever happened, so a failure leaves no tab behind. + if s.documents().len() > opened_before + && let Some(i) = s.active_index() + { + // A just-opened image has no work to lose; if closing fails, one extra tab stays open. + let _ = s.execute("file.close", &json!({ "index": i })); + } + match result { + Ok(out) => { + taken.push(out.clone()); + traced.push(json!({ "path": path, "output": out })); + } + Err(e) => failed.push(json!({ "path": path, "error": e.to_string() })), + } + } + if let Some(i) = active.filter(|i| *i < s.documents().len()) { + s.set_active(i); + } + Ok(json!({ "traced": traced, "failed": failed, "format": format })) +} diff --git a/crates/engine/src/cmd/addons/document/tests.rs b/crates/engine/src/cmd/addons/document/tests.rs index 562a389e8..a1bc5afff 100644 --- a/crates/engine/src/cmd/addons/document/tests.rs +++ b/crates/engine/src/cmd/addons/document/tests.rs @@ -1,5 +1,5 @@ //! Document Tools' commands: the timestamped copy's name and place with the document left as it -//! was, symbols made of layers, and bad params refused. +//! was, symbols made of layers, a folder of images traced, and bad params refused. use std::path::{Path, PathBuf}; @@ -276,3 +276,77 @@ fn export_all_open_documents_keeps_every_page_and_artboard() { assert!(w > 19.0 && w < 30.0, "the SVG shows the art (the square on the second artboard), not the first artboard: viewBox {view}"); let _ = std::fs::remove_dir_all(&dir); } + +/// A 24×24 white PNG with a dark (or red) square in it. +#[cfg(not(target_arch = "wasm32"))] +fn png(path: &Path, dark: bool) { + let mut img = image::RgbaImage::from_pixel(24, 24, image::Rgba([255, 255, 255, 255])); + for y in 6..18 { + for x in 6..18 { + img.put_pixel(x, y, if dark { image::Rgba([0, 0, 0, 255]) } else { image::Rgba([200, 30, 30, 255]) }); + } + } + img.save(path).unwrap(); +} + +/// #4921: each image of the folder is traced and saved under its own name; a file that isn't an +/// image is left alone, one that can't be read is reported, and every document the batch opened +/// is closed again, the active one staying active. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn batch_trace_folder_traces_and_saves_each_image() { + const C: &str = "addon.document.batchTraceFolder"; + let dir = folder("batch-trace"); + let (src, out) = (dir.join("in"), dir.join("out")); + std::fs::create_dir_all(&src).unwrap(); + png(&src.join("logo.png"), true); + png(&src.join("mark.png"), false); + std::fs::write(src.join("notes.txt"), "not an image").unwrap(); + std::fs::write(src.join("broken.png"), b"not a png").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let docs = s.documents().len(); + let r = s.execute(C, &json!({"folder": src, "destinationFolder": out, "format": "svg", "expand": true})).unwrap(); + assert_eq!(r["traced"].as_array().unwrap().len(), 2, "{r}"); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{r}"); + assert!(r["failed"][0]["path"].as_str().unwrap().ends_with("broken.png")); + let mut names: Vec = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["logo.svg", "mark.svg"]); + let svg = std::fs::read_to_string(out.join("logo.svg")).unwrap(); + assert!(svg.contains(" Vec { + vec![cmd!( + "addon.select.byArtboards", + "Select by Artboards", + ["Object", "Plug-ins", "Selection Tools"], + None, + "{artboards: [index…] (0-based, at least one), relation?: \"overlap\" (default: on at least one of them, as select.allOnArtboard takes it: the bounds overlap the artboard or the centre is on it) | \"inside\" (the bounds lie wholly within one of them, edges included) | \"outside\" (on none of them)} select those objects among the ones Select All takes (in isolation mode, the isolated container's); hidden and locked objects, objects on hidden, locked or template layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art → {count}", + has_doc, + by_artboards + )] +} + +fn by_artboards(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.select.byArtboards"; + let relation = text_param(p, "relation", C)?.unwrap_or("overlap"); + if !RELATIONS.contains(&relation) { + return Err(bad(C, "`relation` must be overlap, inside or outside")); + } + let listed = p.get("artboards").ok_or_else(|| bad(C, "missing `artboards`"))?; + let indices = point_indices(listed).ok_or_else(|| bad(C, "`artboards` must be a list of artboard indices"))?; + if indices.is_empty() { + return Err(bad(C, "choose at least one artboard")); + } + let st = s.doc()?; + let rects = + indices.iter().map(|i| st.doc.artboards.get(*i).map(|a| a.rect)).collect::>>().ok_or_else(|| bad(C, "no such artboard"))?; + let symbols = st.doc.symbol_boxes(); + let ids: Vec = selectable(&st.doc, st.isolation) + .into_iter() + .filter(|id| { + let Some(n) = st.doc.node(*id) else { return false }; + match relation { + "inside" => n.geometric_bounds_with(&symbols).is_some_and(|b| { + rects.iter().any(|r| { + vectorcraft_doc::artboard_holds(*r, Point::new(b.x0, b.y0)) && vectorcraft_doc::artboard_holds(*r, Point::new(b.x1, b.y1)) + }) + }), + "outside" => !rects.iter().any(|r| on_artboard(n, &symbols, *r)), + _ => rects.iter().any(|r| on_artboard(n, &symbols, *r)), + } + }) + .collect(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": s.doc()?.selection.objects.len() })) +} diff --git a/crates/engine/src/cmd/addons/select/tests.rs b/crates/engine/src/cmd/addons/select/tests.rs new file mode 100644 index 000000000..bea05ff9e --- /dev/null +++ b/crates/engine/src/cmd/addons/select/tests.rs @@ -0,0 +1,128 @@ +//! Select by Artboards. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +const C: &str = "addon.select.byArtboards"; + +/// A document with three 100 × 100 artboards in a row, 50 pt apart: 0 at x 0, 1 at x 150, 2 at x 300. +fn three_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for x in [150, 300] { + s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(); + } + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn pick(s: &mut Session, p: Value) -> Vec { + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute(C, &p).unwrap(); + let mut ids = s.doc().unwrap().selection.objects.clone(); + assert_eq!(r["count"], json!(ids.len())); + ids.sort(); + ids +} + +#[test] +fn selects_by_overlap_inside_and_outside_several_artboards() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); // inside 0 + let ab = rect(&mut s, 90.0, 40.0, 80.0, 20.0); // across 0 and 1 + let b = rect(&mut s, 160.0, 10.0, 20.0, 20.0); // inside 1 + let c = rect(&mut s, 310.0, 10.0, 20.0, 20.0); // inside 2 + let off = rect(&mut s, 0.0, 200.0, 20.0, 20.0); // on no board + let edge = rect(&mut s, 0.0, 0.0, 100.0, 100.0); // flush with board 0 + let want = |mut v: Vec| { + v.sort(); + v + }; + assert_eq!(pick(&mut s, json!({"artboards": [0, 1]})), want(vec![a, ab, b, edge])); + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "overlap"})), want(vec![a, ab, b, edge])); + // Wholly within one board, edges included; straddling two boards is not inside either. + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "inside"})), want(vec![a, b, edge])); + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "outside"})), want(vec![c, off])); + assert_eq!(pick(&mut s, json!({"artboards": [2], "relation": "outside"})), want(vec![a, ab, b, off, edge])); + // One board does what All on Active Artboard does. + for i in 0..3u64 { + s.execute("select.allOnArtboard", &json!({"artboard": i})).unwrap(); + let mut on = s.doc().unwrap().selection.objects.clone(); + on.sort(); + assert_eq!(pick(&mut s, json!({"artboards": [i]})), on, "board {i}"); + } + // A board listed twice counts once. + assert_eq!(pick(&mut s, json!({"artboards": [2, 2]})), vec![c]); +} + +#[test] +fn hidden_locked_and_isolated_art_follow_select_all() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let hidden = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let locked = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let far = rect(&mut s, 0.0, 300.0, 20.0, 20.0); + s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap(); + s.execute("object.setProps", &json!({"ids": [locked.0], "locked": true})).unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![a]); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "outside"})), vec![far]); + // Art held by a locked artboard is left out. + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![]); + s.execute("artboard.setProps", &json!({"index": 0, "locked": false})).unwrap(); + // In isolation mode only the isolated group's objects are looked at. + let b = rect(&mut s, 20.0, 50.0, 10.0, 10.0); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.isolate", &json!({"id": g})).unwrap(); + let mut both = vec![a, b]; + both.sort(); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "inside"})), both); + assert_eq!(pick(&mut s, json!({"artboards": [1]})), vec![]); +} + +#[test] +fn bad_params_are_errors_and_leave_the_selection() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + for p in [ + json!({}), + json!({"artboards": []}), + json!({"artboards": [3]}), + json!({"artboards": [-1]}), + json!({"artboards": [0.5]}), + json!({"artboards": "0"}), + json!({"artboards": [0], "relation": "near"}), + json!({"artboards": [0], "relation": 1}), + json!({"artboards": [0], "relatoin": "inside"}), + ] { + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert!(s.execute(C, &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{p}"); + } +} + +#[test] +fn a_symbol_instance_is_measured_by_its_art() { + // The symbol's art, (100, 60) to (140, 80), drawn 50 pt up and left: (50, 10) to (90, 30), on + // the 200 × 120 artboard, while the 20 × 20 square around the instance's origin lies off it. + let svg = r##""##; + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "far.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let mut instance = None; + s.doc().unwrap().doc.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. }) { + instance = Some(n.id); + } + }); + let instance = instance.unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![instance]); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "inside"})), vec![instance]); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "outside"})), vec![]); +} diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 0cd8e21ea..a478b8c00 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "Text Tweaker", ["Object", "Plug-ins", "Type Tools"], None, - "{attribute?: \"size\" (pt 0.1..1296) | \"baselineShift\" (pt −1296..1296) | \"rotation\" (° −360..360) | \"hScale\" (% 1..10000) | \"vScale\" (% 1..10000) | \"tracking\" (1/1000 em −1000..10000) | \"leading\" (pt 0.1..5000) | \"strokeWidth\" (pt 0..1000) (\"size\"), from?: the first character's value, to?: the last character's value (each in the attribute's units and range; defaults: size 12 → 36, baselineShift 0 → 12, rotation 0 → 30, hScale and vScale 100 → 150, tracking 0 → 200, leading 12 → 36, strokeWidth 0 → 2), shape?: \"straight\" (an even ramp) | \"ease\" (slow at both ends) | \"bow\" (from at both ends, rising smoothly to `to` in the middle) | \"spike\" (from at both ends, straight up to `to` in the middle) | \"random\" (each character anywhere from `from` to `to`) (\"straight\"), seed?: whole number (0; random: the same seed and text give the same values), skipSpaces?: true (spaces and line breaks keep their values and don't count), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} give the characters one character attribute letter by letter, from `from` to `to` along the shape, as the Character panel sets it, as one undo step; the text stays live and editable. The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: all its text), else every selected type object; each object gets its own ramp. Stroke weight only shows on characters with a stroke → {count (characters changed), ids}", + "{attribute?: \"size\" (pt 0.1..1296) | \"baselineShift\" (pt −1296..1296) | \"rotation\" (° −360..360) | \"hScale\" (% 1..10000) | \"vScale\" (% 1..10000) | \"tracking\" (1/1000 em −1000..10000) | \"leading\" (pt 0.1..5000) | \"strokeWidth\" (pt 0..1000) (\"size\"), from?: the first character's value, to?: the last character's value (each in the attribute's units and range; defaults: size 12 → 36, baselineShift 0 → 12, rotation 0 → 30, hScale and vScale 100 → 150, tracking 0 → 200, leading 12 → 36, strokeWidth 0 → 2), shape?: \"straight\" (an even ramp) | \"ease\" (slow at both ends) | \"bow\" (from at both ends, rising smoothly to `to` in the middle) | \"spike\" (from at both ends, straight up to `to` in the middle) | \"random\" (each character anywhere from `from` to `to`) (\"straight\"), seed?: whole number (0; random: the same seed and text give the same values), skipSpaces?: true (spaces and line breaks keep their values and don't count), id?, start?: byte, end?: byte (range endpoints inside a character round down to its start), ids?: [id…] (instead of id)} give the characters one character attribute letter by letter, from `from` to `to` along the shape, as the Character panel sets it, as one undo step; the text stays live and editable. The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: all its text), else every selected type object; each object gets its own ramp. Stroke weight only shows on characters with a stroke → {count (characters changed), ids}", has_selection, text_tweaker )] @@ -93,25 +93,49 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result { let ranges = type_ranges(s, p, C, |text, _| Some((0, text.len())))?; // Each object's changed characters: (byte, its change), worked out before the edit. let mut plans: Vec<(NodeId, Vec<(usize, CharChange)>)> = vec![]; - let mut count = 0; + let (mut seen, mut count) = (0, 0); for (id, a, b, _) in ranges { let t = text_ref(s, id)?; let text = t.plain_text(); + let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b)); let chars: Vec = text.char_indices().filter(|&(i, c)| (a..b).contains(&i) && !(skip && (c.is_whitespace() || c.is_control()))).map(|(i, _)| i).collect(); - count += chars.len(); - if count > MAX_CHARS { + seen += chars.len(); + if seen > MAX_CHARS { return Err(bad(C, format!("too many characters: at most {MAX_CHARS}"))); } + // Each run's end byte, to find the style a character has now. + let ends: Vec = t + .runs + .iter() + .scan(0, |off, r| { + *off += r.text.len(); + Some(*off) + }) + .collect(); let n = chars.len(); let mut changes = Vec::with_capacity(n); + let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; let value = from + (to - from) * amount(shape, i, n, random); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; + while ends.get(run).is_some_and(|&e| e <= at) { + run += 1; + } + // A character that already has the value is left out, so a run that changes nothing + // makes no step. + if let Some(r) = t.runs.get(run) { + let mut style = r.style.clone(); + change.apply(&mut style); + if style == r.style { + continue; + } + } changes.push((at, change)); } + count += changes.len(); if !changes.is_empty() { plans.push((id, changes)); } diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index 47a1b92b9..7f05cb823 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -62,7 +62,8 @@ fn a_straight_ramp_sizes_each_letter_and_skips_spaces() { select(&mut s, &[id]); let n = steps(&s); let out = s.execute(C, &json!({"attribute": "size", "from": 10, "to": 40})).unwrap(); - assert_eq!(out, json!({"count": 4, "ids": [id.0]})); + // `b` is 20 pt already: three characters change. + assert_eq!(out, json!({"count": 3, "ids": [id.0]})); assert_eq!(steps(&s), n + 1, "one undo step"); let sizes: Vec<(char, f64)> = styles(&s, id).into_iter().map(|(c, st)| (c, st.size)).collect(); assert_eq!(sizes, [('a', 10.0), ('b', 20.0), (' ', 20.0), ('c', 30.0), ('d', 40.0)], "the space keeps its size"); @@ -108,7 +109,8 @@ fn each_object_gets_its_own_ramp() { let (a, b) = (text(&mut s, "abc"), text(&mut s, "de")); select(&mut s, &[a, b]); let out = s.execute(C, &json!({"attribute": "rotation", "from": 0, "to": 30})).unwrap(); - assert_eq!(out["count"], json!(5)); + // Each first character is upright already. + assert_eq!(out["count"], json!(3)); let turns = |id| styles(&s, id).iter().map(|(_, st)| st.rotation).collect::>(); assert_eq!((turns(a), turns(b)), (vec![0.0, 15.0, 30.0], vec![0.0, 30.0])); } @@ -122,6 +124,16 @@ fn a_range_changes_only_its_characters() { assert_eq!(tracking, [0.0, 100.0, 200.0, 300.0, 0.0]); } +#[test] +fn a_range_inside_a_multibyte_character_uses_character_boundaries() { + let mut s = session(); + let id = text(&mut s, "éx"); + let out = s.execute(C, &json!({"id": id.0, "start": 1, "end": 2, "attribute": "tracking", "from": 100, "to": 300})).unwrap(); + assert_eq!(out["count"], 1); + let tracking: Vec = styles(&s, id).iter().map(|(_, st)| st.tracking).collect(); + assert_eq!(tracking, [100.0, 0.0]); +} + #[test] fn random_values_stay_in_range_and_repeat_with_the_seed() { let mut s = session(); @@ -161,6 +173,19 @@ fn empty_text_and_only_spaces_change_nothing() { assert_eq!(steps(&s), n, "no step"); } +#[test] +fn characters_that_already_have_the_value_are_left_out() { + let mut s = session(); + let id = text(&mut s, "abc"); + select(&mut s, &[id]); + let p = json!({"attribute": "rotation", "from": 0, "to": 30}); + // The first character already has 0°. + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 2, "ids": [id.0]})); + let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s)); + assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 0, "ids": []}), "again: nothing to change"); + assert_eq!((s.doc().unwrap().doc.clone(), steps(&s)), (before, n), "no step"); +} + #[test] fn bad_params_are_refused_without_a_change() { let mut s = session(); @@ -179,6 +204,9 @@ fn bad_params_are_refused_without_a_change() { json!({"seed": -1}), json!({"skipSpaces": "yes"}), json!({"ids": [999_999]}), + json!({"id": id.0, "start": -1}), + json!({"id": id.0, "start": 1, "end": 1.5}), + json!({"id": id.0, "end": "2"}), ] { assert!(s.execute(C, &p).is_err(), "{p}"); assert_eq!(s.doc().unwrap().doc, before, "{p}"); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index dc83d24bd..8fb5734ef 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids` and no `target: \"contents\"`, the new fill is a copy of that row, right above it, and becomes the active row", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row", has_doc, |s, p| add_item(s, p, true) ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt", has_doc, |s, p| add_item(s, p, false) ), @@ -178,6 +178,23 @@ pub(crate) struct ActiveItem { } impl Session { + /// The method returns explicit target parameters and the selected appearance effect list. + pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> { + let mut ids = appearance_targets(self, params).ok()?; + let item = item_target(self, params, "effect.apply").ok()?; + let anchor = item.anchor(self, &ids); + let node = self.active()?.doc.node(*ids.get(anchor)?)?; + let at = item.effects_item(&node.appearance, "effect.apply").ok()?; + let effects = node.appearance.effects_at(at)?; + ids.swap(0, anchor); + let ids: Vec = ids.into_iter().map(|id| id.0).collect(); + let aligned = match item { + ItemTarget::Item { aligned, .. } => aligned, + ItemTarget::Top => aligned(params), + }; + Some((json!({"ids": ids, "item": at, "aligned": aligned}), effects.as_slice())) + } + /// The Appearance panel's active fill/stroke: a paint-order index into the first selected /// object's stack, while the selection it was chosen for is unchanged. pub fn appearance_item(&self) -> Option { @@ -340,11 +357,23 @@ pub(crate) fn appearances_differ(d: &vectorcraft_doc::Document, ids: &[NodeId]) /// Indices `idx` of the first target's items (aligned with `aligned`) in each of `ids`: the first /// target's own, and in each other one the items equal to them there (see [`SharedStack`]), each -/// index on its own: one the targets don't share is left out of the others (never taken as the -/// same index there). Not aligned, or one target: the same indices in each. +/// index and each target on its own: an index a target doesn't share with the first is left out +/// of that target (never taken as the same index there), whatever the other targets have. Not +/// aligned, or one target: the same indices in each. pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { - match shared_stack(d, ids).filter(|_| aligned) { - Some(sh) => (0..ids.len()).map(|k| if k == 0 { idx.to_vec() } else { idx.iter().filter_map(|i| sh.item_in(*i, k)).collect() }).collect(), + let aps: Option> = ids.iter().map(|id| d.node(*id).map(|n| &*n.appearance)).collect(); + match aps.filter(|aps| aligned && aps.len() >= 2) { + Some(aps) => aps + .iter() + .enumerate() + .map(|(k, ap)| match aps.first() { + Some(first) if k > 0 => { + let sh = SharedStack::of(&[*first, *ap]); + idx.iter().filter_map(|i| sh.item_in(*i, 1)).collect() + } + _ => idx.to_vec(), + }) + .collect(), None => vec![idx.to_vec(); ids.len()], } } @@ -490,8 +519,8 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result { fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?; let defaults = s.paint.clone(); - // The panel's active Fill row, when the new fill goes on the selection: the new fill is a copy - // of that row, right above it in each target that has it (others get a copy of their top fill). + // The panel's active Fill row, when the new fill goes on the selection: the new fill takes that + // row's paint, right above it in each target that has it (others take their top fill's paint). // Where the new item went in on the active item's object, when it is an image. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); // The active row is a row of the active object's stack, which need not come first in `ids` @@ -514,6 +543,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } _ => vec![None; ids.len()], }; + let before = s.doc()?.doc.clone(); let mut inserted = None; // Where the new fill went in on the active item's object, when it went above the active row. let mut above = None; @@ -529,8 +559,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } continue; } - // A copy of the top fill (stroke); without one, type's characters' and otherwise the - // default paint, so a new fill on a group or type shows. + // The top fill's (stroke's) paint; without one, type's characters' and otherwise the + // default paint, so a new fill on a group or type shows. A new stroke takes the top + // stroke's weight when that stroke has paint, at least 1 pt. let paint = match (n.appearance.paint_at(None, fill), &n.kind) { (Some(p), _) => p.clone(), (None, NodeKind::Text(_)) => super::paint::proxy_paint(n, !fill, None), @@ -560,6 +591,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } } } + super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -578,12 +610,28 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { clear(n); } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; s.active_appearance_item = None; ok() } +/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an +/// edit that replaces whole stacks. +fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { + ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() +} + +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than +/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { + let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { + d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0) + }; + ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() +} + /// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A /// group keeps no fill or stroke rows of its own. fn clear(n: &mut Node) { @@ -619,7 +667,9 @@ fn transfer(s: &mut Session, p: &Value) -> Result { clear(n); (n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false); } - Ok(count_changed(d, &before, &[source, target])) + let changed = if copy { vec![target] } else { vec![source, target] }; + let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed)); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -672,7 +722,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { } } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; s.active_appearance_item = None; ok() @@ -694,9 +745,14 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let before = s.doc()?.doc.clone(); let per = items_per_target(&before, &ids, &[idx], aligned(p)); super::overprint::edit_counted(s, "Appearance", |d| { + let mut edited = Vec::new(); for (id, at) in ids.iter().zip(&per) { let Some(&idx) = at.first() else { continue }; let Some(n) = d.node_mut(*id) else { continue }; + let items = &n.appearance.items; + if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) { + edited.push((*id, fill)); + } if p.get("weight").is_none() && let Some(pa) = &paint { @@ -727,7 +783,9 @@ fn set_item(s: &mut Session, p: &Value) -> Result { *vis = v; } } - Ok(count_changed(d, &before, &ids)) + // The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again. + let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -770,7 +828,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count())); ok() @@ -796,7 +855,8 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); } - let per = items_per_target(&s.doc()?.doc, &ids, &idx, aligned(p)); + let before = s.doc()?.doc.clone(); + let per = items_per_target(&before, &ids, &idx, aligned(p)); s.edit("Duplicate Item", |d, _| { let mut any = false; for (id, idx) in ids.iter().zip(&per) { @@ -834,7 +894,11 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { } } } - if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) } + if !any { + return Err(bad(C, format!("no item at index {}", idx[0]))); + } + super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). let m = idx.len(); @@ -893,7 +957,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { n.appearance.set_contents_at(k); } } - Ok(count_changed(d, &before, &ids)) + let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids)); + Ok(count_changed(d, &before, &ids) + usize::from(kept)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -936,7 +1001,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { s.eyedropper_sample = Some(node); } let range = range.filter(|(id, _, _)| targets.contains(id)); + // The highlighted characters' type objects and ranges: after Select All in threaded text, all + // the text of every frame of the story. + let story = super::textedit::editing_story(s); + let ranges: Vec<(NodeId, Option<(usize, usize)>)> = match range { + Some((id, _, _)) if story.contains(&id) => story.iter().map(|f| (*f, None)).collect(), + Some((id, start, end)) => vec![(id, Some((start, end)))], + None => vec![], + }; // Character styles cannot represent object transparency. Refuse before even changing // paint defaults; applying it to the object would also alter the unselected characters. - if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) { - let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; - if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal { - return Err(bad( - C, - "object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object", - )); + if attrs.appearance.transparency { + for (id, _) in &ranges { + let target = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?; + if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal { + return Err(bad( + C, + "object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object", + )); + } } } if !reverse { @@ -1051,19 +1127,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if targets.is_empty() || attrs.is_empty() { return Ok(json!({ "ids": [] })); } - if let Some((id, start, end)) = range { + if !ranges.is_empty() { + let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Eyedropper", |d| { - let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; - let placed = picked.placed(Some(t.local_bounds())); - vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); - if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { - t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); + for &(id, range) in &ranges { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs))); + let placed = picked.placed(Some(t.local_bounds())); + vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); + if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { + t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); + } + super::typecmd::refresh_bounds(t); } - super::typecmd::refresh_bounds(t); - Ok(count_changed(d, &before, &[id])) + Ok(count_changed(d, &before, &ids)) })?; - return Ok(json!({ "ids": [id.0] })); + return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); } // When every target comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -1073,7 +1153,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { picked.apply(n, &attrs, append); } } - Ok(count_changed(d, &before, &targets)) + let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets)); + Ok(count_changed(d, &before, &targets) + usize::from(kept)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index adfb221ef..a6505a057 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -19,7 +19,7 @@ use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; use super::swatch::{anchor, names_param}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, + CommandSpec, Result, Session, bad, check_numbers, cmd, color_param, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, }; use crate::EngineError; @@ -35,7 +35,9 @@ use brushes::{ }; use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; use symbols::selected_instances; -pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol}; +pub(crate) use symbols::{ + expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol, +}; use symbols::{ symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, @@ -201,7 +203,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}", has_selection, symbol_break_link ), @@ -302,7 +304,7 @@ pub fn specs() -> Vec { "Symbolism Tool", [], None, - "{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}", + "{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}", has_doc, symbol_adjust ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 9a3606311..77a61c58a 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -1,7 +1,7 @@ use super::dynamic::{fit_overrides, type_id, type_param}; use super::{ Affine, AppearanceItem, Arc, CURRENT_SYMBOL, Color, Document, EngineError, FillLayer, HALF, Node, NodeId, NodeKind, Paint, Point, Result, - SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_value, ids_json, json, + SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_param, ids_json, json, name_param, number_or, ok, remove_each, roots, selection_art, separate, set_unknown, str_param, switch_or, }; @@ -341,7 +341,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { /// The plain art of instance `inst` in document space (fresh ids), with the instance's own /// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice -/// guides when it has them. +/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it +/// when the art has a mask of its own. fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; @@ -361,13 +362,21 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { art.opacity *= inst.opacity; art.blend = inst.blend; } + if let Some(mask) = &inst.extras().mask { + if art.extras().mask.is_some() { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.extras_mut().mask = Some(mask.clone()); + } Some(art) } /// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its /// symbol's object count with the art of its opacity masks, or /// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus -/// the group that opacity or a blending mode adds. 0 for any other object. +/// the group that opacity, a blending mode or the instance's opacity mask adds, plus the art of +/// the instance's opacity mask. 0 for any other object. fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 }; let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 }; @@ -378,7 +387,8 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64, None => sym.art.count_with_masks() as u64, }; - most.saturating_add(1) + let mask = inst.extras().mask.as_ref().map_or(0, |m| m.art.count_with_masks() as u64); + most.saturating_add(1).saturating_add(mask) } /// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the @@ -402,7 +412,64 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result