diff --git a/ASSETS.md b/ASSETS.md index aa3bfeda0f..e4a17c7640 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 137e9a2be7..ae8a3ee6c7 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 1af59ddfe4..924a1a68dc 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 002fa016ac..4bf18085bd 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/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 47d26eb7b6..f462132317 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 e3d946ef0a..aad5d7d083 100644 --- a/apps/vectorcraft/src/control_server/tests.rs +++ b/apps/vectorcraft/src/control_server/tests.rs @@ -334,7 +334,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/host_services.rs b/apps/vectorcraft/src/host_services.rs index 16fe1d469c..379d6fd242 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -461,24 +461,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 +476,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 +629,37 @@ 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"); } } diff --git a/contributors/people.toml b/contributors/people.toml index db94e2a890..f4e508fdf5 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -193,3 +193,11 @@ 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" diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 7d87991864..6c65246176 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 ae0c170f3b..c52f7f6309 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 d7e20654a1..72fa4f7b34 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 e4984c3332..d99858511e 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/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs index 1c3334f0dc..01b9c9fc59 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/graph.rs b/crates/doc/src/graph.rs index 34c2a1dd99..b19adc8b02 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/node/tests.rs b/crates/doc/src/node/tests.rs index 1669de1a27..cde4004aea 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 6ec18bcfa2..ab347529e6 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/effects/src/stroke/width.rs b/crates/effects/src/stroke/width.rs index 87eca97d78..324fddfac9 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/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index dc83d24bd5..178a4ec545 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -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 { @@ -514,6 +531,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; @@ -560,6 +578,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 +597,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 +654,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 +709,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 +732,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 +770,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 +815,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 +842,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 +881,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 +944,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 +988,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result 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/cut.rs b/crates/engine/src/cmd/cut.rs index 90bd276c9b..48f2c31e62 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -138,8 +138,12 @@ fn cut_targets(s: &Session) -> Result> { /// What a cut does to one object: its new pieces (none deletes it). type Plan = Vec<(NodeId, Vec)>; -/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step. -fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option>) -> Result { +/// What a cut makes of one object: its pieces (`None`: unchanged), or the pathops refusal. +type Cut = std::result::Result>, po::PathOpsError>; + +/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step. A shape the +/// booleans refuse (too complex, too far out) fails the command with the art untouched: it is not "nothing on that side". +fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Cut) -> Result { let targets = cut_targets(s)?; if targets.is_empty() { return Err(bad(cmd, "select paths or compound paths")); @@ -151,7 +155,8 @@ fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, Fill if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) { return Err(bad(cmd, "paths contain invalid coordinates")); } - if let Some(pieces) = cut(&pd, rule) { + let pieces = cut(&pd, rule).map_err(|e| bad(cmd, format!("the cut cannot be made on this art: {e}")))?; + if let Some(pieces) = pieces { plan.push((id, pieces)); } } @@ -184,9 +189,9 @@ fn reach(pd: &PathData) -> Rect { } /// `pd ∩ region`, dropping slivers. -fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData { - let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect); - if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r } +fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> std::result::Result { + let r = po::try_boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect, po::DEFAULT_PRECISION)?; + Ok(if po::try_area(&r, FillRule::NonZero)? <= 1e-6 { PathData::default() } else { r }) } // ---------- Line Cut ---------- @@ -198,24 +203,26 @@ fn line_cut(s: &mut Session, p: &Value) -> Result { } /// `pd` split by the line: the pieces on both sides, `None` when the line misses it. -fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option> { +fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Cut { if pd.is_closed() { let around = reach(pd); - let a = intersect(pd, rule, &axis.half_plane(1.0, around)); - let b = intersect(pd, rule, &axis.half_plane(-1.0, around)); + let a = intersect(pd, rule, &axis.half_plane(1.0, around))?; + let b = intersect(pd, rule, &axis.half_plane(-1.0, around))?; if a.is_empty() || b.is_empty() { - return None; + return Ok(None); } - return Some(components(&a).into_iter().chain(components(&b)).collect()); + return Ok(Some(components(&a).into_iter().chain(components(&b)).collect())); } // Open paths: the parts on each side (each pass removes the other side). - let a = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0)?; - let b = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0)?; - Some(if pd.subpaths.len() == 1 { + let (Some(a), Some(b)) = (erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0), erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0)) + else { + return Ok(None); + }; + Ok(Some(if pd.subpaths.len() == 1 { a.into_iter().chain(b).collect() } else { vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())] - }) + })) } // ---------- Rectangle Cut ---------- @@ -238,25 +245,34 @@ fn rect_cut(s: &mut Session, p: &Value) -> Result { } /// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside. -fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option> { - let b = pd.bounds()?; +fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Cut { + let Some(b) = pd.bounds() else { return Ok(None) }; if rect.inflate(1e-9, 1e-9).contains_rect(b) { - return None; + return Ok(None); } if pd.is_closed() { - let r = - intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)])); - return Some(if r.is_empty() { vec![] } else { vec![r] }); + // Wholly outside: nothing is left, whatever the booleans would make of the rectangle's coordinates. + if rect.intersect(b).is_zero_area() { + return Ok(Some(vec![])); + } + let r = intersect( + pd, + rule, + &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]), + )?; + return Ok(Some(if r.is_empty() { vec![] } else { vec![r] })); } let inside = rect.inflate(1e-9, 1e-9); // Sampled at least every half of the rectangle's shorter side, so a line through it is found. let step = rect.width().min(rect.height()) / 2.0; - let pieces = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?; - Some(if pd.subpaths.len() == 1 || pieces.is_empty() { + let Some(pieces) = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1)) else { + return Ok(None); + }; + Ok(Some(if pd.subpaths.len() == 1 || pieces.is_empty() { pieces } else { vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())] - }) + })) } // ---------- Mirror & Cut ---------- @@ -287,7 +303,7 @@ fn mirror_cut(s: &mut Session, p: &Value) -> Result { other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))), }; let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?; - run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign))) + run_cut(s, C, "Mirror & Cut", &|pd, rule| mirror(pd, rule, &axis, sign).map(Some)) } /// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs @@ -309,22 +325,22 @@ fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result Vec { +fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> std::result::Result, po::PathOpsError> { let m = axis.reflection(); if pd.is_closed() { - let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd))); + let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)))?; if kept.is_empty() { - return vec![]; + return Ok(vec![]); } - let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union); - return if joined.is_empty() { vec![] } else { vec![joined] }; + let joined = po::try_boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union, po::DEFAULT_PRECISION)?; + return Ok(if joined.is_empty() { vec![] } else { vec![joined] }); } let kept = match erase_outline(pd, f64::INFINITY, &|q| axis.side(q) * sign < 0.0) { Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(), None => pd.subpaths.clone(), }; let subs: Vec = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect(); - if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] } + Ok(if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] }) } /// An open run and its reflection, joined where its ends touch the axis (closed when both do); @@ -399,4 +415,28 @@ mod tests { let out = mirror_open(&sp, &axis, axis.reflection()); assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3)); } + + /// A shape the booleans refuse (here: so far from the origin that the sweep tolerance exceeds the precision) is an error, + /// not "no pieces": that would make Mirror & Cut delete it and Rectangle Cut crop it to nothing. + #[test] + fn a_shape_the_booleans_refuse_is_an_error_not_no_pieces() { + let far = polygon(&[ + Point::new(1e15, 1e15), + Point::new(1e15 + 100.0, 1e15), + Point::new(1e15 + 100.0, 1e15 + 100.0), + Point::new(1e15, 1e15 + 100.0), + ]); + let axis = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) }; + let refused = Some(po::PathOpsError::Degenerate); + assert_eq!(mirror(&far, FillRule::NonZero, &axis, 1.0).err(), refused); + assert_eq!(split_by_line(&far, FillRule::NonZero, &axis).err(), refused); + // A rectangle that far out, against art near the origin, still crops it to nothing. + let far_rect = Rect::new(1e15, 1e15, 1e15 + 100.0, 1e15 + 100.0); + let near_art = polygon(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)]); + assert_eq!(crop(&near_art, FillRule::NonZero, far_rect).unwrap(), Some(vec![])); + // Near the origin the same cuts work. + let near = polygon(&[Point::new(-50.0, 0.0), Point::new(50.0, 0.0), Point::new(50.0, 100.0), Point::new(-50.0, 100.0)]); + assert_eq!(mirror(&near, FillRule::NonZero, &axis, 1.0).unwrap().len(), 1); + assert_eq!(split_by_line(&near, FillRule::NonZero, &axis).unwrap().map(|p| p.len()), Some(2)); + } } diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 58d5daf17a..413e8c401a 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -304,21 +304,16 @@ fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result Option { let st = s.active()?; let mut out = None; - for id in &st.selection.objects { - st.doc.node(*id)?.walk(&mut |n| { - if out.is_none() - && let NodeKind::Text(t) = &n.kind - { - out = Some(t.first_style()); - } - }); - if out.is_some() { - break; + each_selected_text(s, |id| { + if let Some(NodeKind::Text(t)) = st.doc.node(id).map(|n| &n.kind) { + out = Some(t.first_style()); } - } + out.is_none() + }); out } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index a807010711..b125e48a88 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -420,7 +420,10 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { /// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { let f = super::writable(C, Some(format), None)?; - encode_all(doc, f.id, p)?.single(f) + let enc = encode_all(doc, f.id, p)?; + // The error for a version 7 .ai of several artboards names the legacy .ai. + let legacy = f.id == "ai" && super::legacy_ai::wanted(C, f, p)?; + enc.single(&if legacy { super::legacy_ai::named(f) } else { *f }) } /// [`encode`], with every file the export writes. diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 1d9d23590f..d4820242d9 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -41,7 +41,7 @@ use super::super::{Affine, EngineError, NodeId, Result, bad}; use super::{ArtboardPick, Encoded, Format}; /// Does `p` ask a save in `f` for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown -/// version is an error, and so is version 7 for a `.ait` template, which is PDF-compatible only. +/// version is an error, and so is version 7 for a `.ait` template, which is never a legacy file. pub(super) fn wanted(cmd: &str, f: &Format, p: &Value) -> Result { let legacy = match p.get("aiVersion") { None | Some(Value::Null) => false, @@ -51,11 +51,17 @@ pub(super) fn wanted(cmd: &str, f: &Format, p: &Value) -> Result { Some(v) => return Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))), }; if legacy && f.id == "ait" { - return Err(bad(cmd, "aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible")); + return Err(bad(cmd, "aiVersion 7 writes legacy .ai files, not .ait templates")); } Ok(legacy) } +/// `f` (the `.ai` format, whose label names the PDF-compatible file) named, in its errors, as the +/// legacy `.ai` file. +pub(super) fn named(f: &Format) -> Format { + Format { label: "legacy .ai (version 7)", ..*f } +} + /// What a legacy `.ai` does with the images it can't hold (`transparentImages`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SeeThrough { @@ -109,7 +115,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result } let mode = see_through(cmd, p)?; let (hidden, outline_text) = (hidden_objects(cmd, p)?, outline_text(cmd, p)?); - let picked: ArtboardPick = super::encode::options(f, p)?; + let picked: ArtboardPick = super::encode::options(&named(f), p)?; let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect()); let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() }; if boards.len() > 1 { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ac46299de5..335a3200d1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -334,9 +334,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } "pdf" | "ai" | "ait" => { // Saved with Preserve Editing: the document it carries, unless some pages are picked. - let editing = opts - .pages - .is_none() + let editing = (opts.pages.is_none() && opts.native) .then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref())) .flatten() .map(|e| (e.intact, move || Some(e.data))); diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 6c2b002580..1331d6f894 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -105,6 +105,12 @@ pub const AI_OPTIONS: &[FormatOption] = &[ default: "true", description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)", }, + FormatOption { + name: "editingData", + ty: "boolean", + default: "true", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, paths, fills and strokes, colors, swatches and artboards, with type, gradients, patterns, images, symbols, clipping and live objects as plain filled and stroked paths, without transparency (written only with every artboard, as Preserve Editing is)", + }, FormatOption { name: "aiVersion", ty: "string", diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 2a3829acd6..812b8ac97b 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -263,7 +263,7 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str = pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { // Keep the editable object tree independent of preview fallbacks used by the drawn pages. let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); - let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?; + let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?; warnings.extend(drawn_warnings); warnings.extend(overflow_warning(cmd, &full, p)?); Ok((bytes, warnings)) @@ -281,8 +281,15 @@ fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result Result>) -> Result<(Vec, Vec)> { +/// native document with its save options, whose pages may be left blank), and `private()`, the +/// editing copy of the art for other apps with what it couldn't hold, on every page. +pub(super) fn encode_carrying( + cmd: &str, + doc: &Document, + p: &Value, + native: impl FnOnce() -> Result>, + private: impl FnOnce() -> Result)>>, +) -> Result<(Vec, Vec)> { let mut opts = options(cmd, doc, p)?; // The pages' background colors, behind the art (the editing data keeps them as colors). let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect()); @@ -295,6 +302,10 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl if opts.settings.preserve_editing { if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) { opts.native = Some(native()?); + if let Some((data, lost)) = private()? { + opts.private_data = Some(data); + warnings.extend(lost); + } } else { opts.settings.preserve_editing = false; warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string()); diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 80575c23f8..e646c43f89 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -39,6 +39,9 @@ pub struct LoadOptions { /// A `.ai` opens from its editing copy even where that draws differently from its PDF part /// (which can be older than it), with a warning, instead of from the PDF part. pub trust_editing_data: bool, + /// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries; + /// off, it opens as another app would open it, from the editing copy of its art or its pages. + pub native: bool, } impl Default for LoadOptions { @@ -54,6 +57,7 @@ impl Default for LoadOptions { dxf: Default::default(), editing_data: true, trust_editing_data: false, + native: true, } } } @@ -91,14 +95,14 @@ impl LoadOptions { Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false), }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data }) + Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, native: true }) } /// Does the document read only part of the file, or read it differently (a page range, another - /// box, a password, without its hidden layers)? Writing it back would lose the rest, so Save - /// doesn't (it asks for a name). + /// box, a password, without its hidden layers, without the VectorCraft document it carries)? + /// Writing it back would lose the rest, so Save doesn't (it asks for a name). pub fn is_partial(&self) -> bool { - self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers + self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native } /// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index de67968dad..cb513e3166 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -249,11 +249,6 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { if let Some((key, top, inner)) = disagreeing_option(p, is_svg(format)).map_err(|e| bad(cmd, e))? { return Err(bad(cmd, format!("`{key}: {top}` and `options.{key}: {inner}` disagree: give the option once"))); } - let notes = NATIVE_OPTIONS - .iter() - .filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k)) - .map(|k| format!("`{k}` was not used: {} files don't read it", format.label)) - .collect(); let mut given: Map = match p.get("options") { Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(), None | Some(Value::Null) => Map::new(), @@ -293,6 +288,12 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { if let Some(path) = &path { super::check_not_lossy_overwrite(st, path, p, cmd)?; } + let written = written_as(cmd, format, &Value::Object(options.clone())); + let notes = NATIVE_OPTIONS + .iter() + .filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k)) + .map(|k| format!("`{k}` was not used: {} files don't read it", written.label)) + .collect(); Ok(SavePlan { mode, path, format, options, name, folder, modified, notes }) } @@ -575,6 +576,28 @@ fn blank_pages(doc: &Document) -> Document { /// Why a `.ai` file saved without PDF content looks empty elsewhere. const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages"; +/// The same, for a file that carries the editing copy of its art. +const NOT_PDF_COMPATIBLE_EDITING: &str = + "saved without PDF content: VectorCraft and apps that read its editing data open it, other apps show empty pages"; + +/// `f` (`.ai` or `.ait`) named, in its option errors, as a file saved without PDF content. +fn without_pdf(f: &Format) -> Format { + let label = if f.id == "ait" { "non-PDF-compatible .ait template" } else { "non-PDF-compatible .ai" }; + Format { label, ..*f } +} + +/// `f` named, in its errors and notes, as the file a save with options `p` writes: a version 7 +/// `.ai` as the legacy `.ai`, and a `.ai` or `.ait` with `pdfCompatible: false` as one saved +/// without PDF content. +fn written_as(cmd: &str, f: &Format, p: &Value) -> Format { + if f.id == "ai" && super::legacy_ai::wanted(cmd, f, p).unwrap_or(false) { + super::legacy_ai::named(f) + } else if matches!(f.id, "ai" | "ait") && !bool_or(p, "pdfCompatible", true) { + without_pdf(f) + } else { + *f + } +} /// `e`, an error of the export encoders, under the save command `cmd` instead of /// `document.export`. @@ -585,8 +608,17 @@ fn named_for_save(cmd: &str, e: crate::EngineError) -> crate::EngineError { } } -/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the -/// same file). +/// The `.ai` option `editingData`: whether the file also carries the editing copy of the art that +/// other apps read (default on). +#[derive(Default, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct AiEditingData { + editing_data: Option, +} + +/// The shared Save and Export encoder for `.ai` files (and `.ait` templates, the same file): +/// PDF-compatible by default, without PDF content (`pdfCompatible: false`), or legacy version 7 +/// `.ai` files (`aiVersion: "7"`). pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result { if super::legacy_ai::wanted(cmd, f, p)? { return super::legacy_ai::encode(cmd, f, doc, p); @@ -605,8 +637,9 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res } } } - let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?; let pdf_compatible = bool_or(¶ms, "pdfCompatible", true); + let f = &if pdf_compatible { *f } else { without_pdf(f) }; + let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?; if !pdf_compatible { let picked: ArtboardPick = super::encode::options(f, ¶ms)?; let picked = picked.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; @@ -614,16 +647,46 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res return Err(bad(cmd, "pdfCompatible: false needs every artboard to keep the editable artwork")); } } - let pages = doc.without_edit_modes(); + let edited = doc.without_edit_modes(); // Resolve placed files only for the drawn pages. Missing files become preview images there; // the native attachment keeps the original placed objects and their relinkable metadata. - let (pages, placed_warnings) = - if pdf_compatible { crate::cmd::place::document::full_documents(&pages) } else { (std::borrow::Cow::Owned(blank_pages(&pages)), Vec::new()) }; + let (pages, placed_warnings) = if pdf_compatible { + crate::cmd::place::document::full_documents(&edited) + } else { + (std::borrow::Cow::Owned(blank_pages(&edited)), Vec::new()) + }; let native = || super::native::ai_native(cmd, doc, &native_options); - let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native)?; + let other_apps: AiEditingData = super::encode::options(f, ¶ms)?; + let carries_editing = std::cell::Cell::new(false); + let private = || { + if !other_apps.editing_data.unwrap_or(true) { + return Ok(None); + } + // The art with placed files resolved as the pages draw them, also when the pages are blank. + let resolved; + let (art, mut resolved_warnings): (&Document, Vec) = if pdf_compatible { + (&pages, Vec::new()) + } else { + let (d, w) = crate::cmd::place::document::full_documents(&edited); + resolved = d; + (&resolved, w) + }; + // With the artboards' background colors, as the pages draw them. + let art = super::export::with_artboard_fills(art, ¶ms, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?; + let o = vectorcraft_eps::AiEditingOptions { + title: doc.title.clone(), + created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time), + }; + let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?; + // Placed files that weren't found are written as their previews here too. + resolved_warnings.extend(lost); + carries_editing.set(true); + Ok(Some((data, resolved_warnings))) + }; + let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?; warnings.extend(placed_warnings); if !pdf_compatible { - warnings.push(NOT_PDF_COMPATIBLE.to_string()); + warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string()); } Ok(Encoded { warnings, ..Encoded::one(bytes) }) } diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 50d09b0256..e0f6600d95 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -127,6 +127,45 @@ fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() { assert!(result["warnings"].to_string().contains("`ZZ` (1)"), "{result}"); } +#[test] +fn a_differing_object_keeps_the_layers_with_a_note() { + let square = |cmyk: &str, x: f64| format!("{cmyk} k\n{}f\n", ai::rect(x, 40.0, x + 16.0, 56.0)); + let front = square("0 0 0 1", 90.0) + &square("0 0 1 0", 160.0); + let data = ai::editing_data(200.0, 100.0, &(ai::layer("Back", &square("0 0 1 0", 20.0)) + &ai::layer("Front", &front))); + let page = "0 0 1 0 k 20 40 16 16 re f 1 0 0 0 k 90 40 16 16 re f 0 0 1 0 k 160 40 16 16 re f"; + let mut session = Session::new(); + let result = open(&mut session, "differs.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap(); + let doc = &session.doc().unwrap().doc; + assert_eq!(names(&doc.layers), ["Back", "Front"], "{result}"); + let front = doc.layers[1].children().unwrap(); + assert!(matches!(front[0].kind, NodeKind::Group { .. }) && matches!(front[1].kind, NodeKind::Path { .. }), "{result}"); + let replacement = &front[0].children().unwrap()[0]; + assert!( + matches!(replacement.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill)) + if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))), + "{result}" + ); + assert!( + result["warnings"].to_string().contains("objects that drew differently from the file's own page came in as its art: Front (1)"), + "{result}" + ); + // An export to the file's own path is refused unless it is given `acknowledgeLoss`. + let d = std::env::temp_dir().join(format!("vc-aidiffers-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("differs.ai"); + let file = ai::ai(&compressed(data.as_bytes()), page); + std::fs::write(&path, &file).unwrap(); + let path = path.to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let e = s.execute("document.export", &json!({"path": path})).unwrap_err().to_string(); + assert!(e.contains("left things out") && e.contains("objects that drew differently"), "{e}"); + assert_eq!(std::fs::read(&path).unwrap(), file); + s.execute("document.export", &json!({"path": path, "acknowledgeLoss": true})).unwrap(); + assert_ne!(std::fs::read(&path).unwrap(), file); + std::fs::remove_dir_all(d).ok(); +} + /// A palette of three swatches and a color group `Inks` holding a fourth. const INKS_PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\ 0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n"; diff --git a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs index 28e170e063..9a7684686a 100644 --- a/crates/engine/src/cmd/fileio/tests_artboard_fill.rs +++ b/crates/engine/src/cmd/fileio/tests_artboard_fill.rs @@ -97,6 +97,18 @@ fn pdf_and_svg_show_the_color_and_their_editing_data_keeps_it_as_the_artboards() assert_eq!(back.layers.len(), doc.layers.len(), "no art made of it"); } +#[test] +fn the_ai_editing_copy_for_other_apps_draws_the_color_as_the_pages_do() { + let mut s = session(); + let opts = LoadOptions { native: false, ..Default::default() }; + for (more, fill) in [(json!({}), true), (json!({"includeArtboardFill": false}), false)] { + let l = load::load_with("a.ai", &file(&mut s, with(json!({"format": "ai"}), &more)), &opts).unwrap(); + assert!(l.editing_data && !l.restored, "{more}: {:?}", l.warnings); + let names: Vec = l.doc.layers.iter().filter_map(|l| l.name.clone()).collect(); + assert_eq!(names.iter().any(|n| n == "Artboard Fill"), fill, "{more}: {names:?}"); + } +} + #[test] fn eps_and_postscript_print_draw_the_color() { let mut s = session(); diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 35d3945402..7b9f9774f3 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -403,3 +403,77 @@ fn ai_export_without_artboards_keeps_art_outside_the_original_pages() { assert_eq!(comparable(&s.doc().unwrap().doc), before); assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err()); } + +/// The layers of `doc` as their names and the kinds and bounds of what they hold, rounded. +fn outline(doc: &vectorcraft_doc::Document) -> Vec { + fn walk(nodes: &[std::sync::Arc], depth: usize, out: &mut Vec) { + for n in nodes { + let b = n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| v.round() as i64)); + let kind = match &n.kind { + vectorcraft_doc::NodeKind::Layer { .. } => "layer", + vectorcraft_doc::NodeKind::Group { .. } => "group", + vectorcraft_doc::NodeKind::Path { .. } => "path", + _ => "other", + }; + out.push(format!("{depth} {kind} {:?} {} {b:?}", n.name, n.visible)); + walk(n.children().map(Vec::as_slice).unwrap_or_default(), depth + 1, out); + } + } + let mut out = vec![]; + walk(&doc.layers, 0, &mut out); + out +} + +/// A `.ai` also carries the editing copy of its art that other apps read: opened without the +/// VectorCraft document it carries, it gives back the same layers, groups and paths, its page +/// matching them. `editingData: false` leaves the copy out. +#[test] +fn ai_files_carry_an_editing_copy_for_other_apps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 60, "y": 10, "width": 50, "height": 30})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("layer.new", &json!({"name": "Top"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 120, "y": 50, "width": 60, "height": 40})).unwrap(); + let before = outline(&s.doc().unwrap().doc); + let bytes = b64(&s.execute("document.export", &json!({"format": "ai"})).unwrap()); + let private = vectorcraft_pdf::illustrator_data(&bytes, None).expect("the editing copy for other apps"); + assert!(private.starts_with(b"%AI24_ZStandard_Data")); + let opts = LoadOptions { native: false, ..Default::default() }; + let l = load::load_with("art.ai", &bytes, &opts).unwrap(); + assert!(l.editing_data && !l.restored); + assert!(!l.warnings.iter().any(|w| w.contains("editing data")), "{:?}", l.warnings); + assert_eq!(outline(&l.doc), before); + let r = s.execute("document.export", &json!({"format": "ai", "editingData": false})).unwrap(); + assert_eq!(vectorcraft_pdf::illustrator_data(&b64(&r), None), None); + assert!(s.execute("document.export", &json!({"format": "ai", "editingData": "no"})).is_err()); +} + +/// A `.ai` whose editing copy for other apps differs from the VectorCraft document it carries (the +/// copy has no transparency) opens in VectorCraft from the VectorCraft document. +#[test] +fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let bytes = b64(&s.execute("document.export", &json!({"format": "ai"})).unwrap()); + let opacities = |doc: &vectorcraft_doc::Document| { + let mut out = vec![]; + doc.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }) { + out.push(n.opacity); + } + }); + out + }; + let copy = load::load_with("art.ai", &bytes, &LoadOptions { native: false, trust_editing_data: true, ..Default::default() }).unwrap(); + assert!(copy.editing_data && !copy.restored); + assert_eq!(opacities(©.doc), [1.0]); + let opened = load::load_with("art.ai", &bytes, &LoadOptions::default()).unwrap(); + assert!(opened.restored); + assert_eq!(opacities(&opened.doc), [0.5]); +} diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs index 862f3f0bed..86cb84cfcb 100644 --- a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs @@ -141,6 +141,8 @@ fn load_options_read_text_and_layers() { assert!(o.is_partial(), "without its hidden layers, Save doesn't write the file back"); let d = LoadOptions::default(); assert_eq!((d.text_as, d.layers, d.is_partial()), (TextAs::Text, true, false)); + let n = LoadOptions { native: false, ..Default::default() }; + assert!(n.is_partial(), "without the document it carries, Save doesn't write the file back"); } #[test] diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 2b9bb775a1..e88e2810c5 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,7 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_paints, paint_targets}; +pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, paint_targets}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 8efdc2df96..6db9a0fe45 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -5,7 +5,7 @@ use super::labels::{ FILLED_LINE_BUDGET, Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, label_size, label_width, polyline, recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::series_marks; +use super::series::{series_marks, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -244,7 +244,8 @@ impl<'a> Plot<'a> { if marker_cost(g, &designs) > MARKER_BUDGET { designs.clear(); } - let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1); + // The same dimensions `GraphSpec::cells` reads: raw rows can be longer than the grid they are read through. + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); Plot { g, @@ -253,7 +254,7 @@ impl<'a> Plot<'a> { designs, r: g.rect, nser, - ncat: g.rows.len().max(1), + ncat: g.rows.len().clamp(1, vectorcraft_doc::MAX_GRAPH_CATEGORIES), vs: label_size(&g.label_styles, Role::Value), cs: label_size(&g.label_styles, Role::Category), ls: label_size(&g.label_styles, Role::Legend), @@ -1001,7 +1002,7 @@ impl<'a> Plot<'a> { } } let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); - let grp = self.b.series(&name, s, items); + let grp = self.b.series(&name, s, with_series_opacity(g, s, items)); self.b.out.push(grp); } self.b.out.append(&mut self.on_top); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 0f035b55c9..1b864ba92e 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,5 +1,5 @@ use super::labels::{Role, label_style}; -use super::{CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; +use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- @@ -25,6 +25,31 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P (fill, stroke, width) } +/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their +/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is +/// held to 0..1, and one that is not a number is left out. +pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { + let Some(slot) = g.series_paints.get(i) else { return items }; + let kept = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); + let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity)); + if fill.is_none() && stroke.is_none() { + return items; + } + for item in &mut items { + if !matches!(item.kind, NodeKind::Path { .. }) { + continue; + } + let ap = &mut Arc::make_mut(item).appearance; + if let (Some(o), Some(f)) = (fill, ap.fill_mut()) { + f.opacity = o; + } + if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) { + st.opacity = o; + } + } + items +} + /// Where a node inside a graph's generated series art belongs: the graph, the series group and /// the series index stored on that group. #[derive(Clone, Copy)] @@ -119,6 +144,51 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo changed } +/// Copy the opacity of the topmost fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: +/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind +/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity +/// changed. +pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { + if edited.is_empty() { + return false; + } + let members = series_members(doc); + if members.is_empty() { + return false; + } + let captures: Vec<_> = edited + .iter() + .filter_map(|(id, fill)| { + // Only the paths the graph draws again, not the copy of a graph design. + let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; + let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; + let opacity = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill).map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); + Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) + }) + .collect(); + let mut changed = false; + for (graph, index, fill, kept) in captures { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + // A fully opaque row on a series the graph keeps nothing for leaves the graph as it is. + if kept.is_none() && spec.series_paints.len() <= index { + continue; + } + let was = spec.series_paints.clone(); + // `index` is below the graph's series count (`series_members`). + if spec.series_paints.len() <= index { + spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); + } + let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + if fill { + slot.fill_opacity = kept; + } else { + slot.stroke_opacity = kept; + } + changed |= spec.series_paints != was; + } + changed +} + /// Graph Data › Transpose row/column: categories become series and series categories. pub(super) fn transpose(g: &mut GraphSpec) { let cells = g.cells(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 5876de1d6e..2be4bbf48e 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -289,6 +289,239 @@ fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() { assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green))); } +/// An opacity set on the fill or stroke item of one series mark is kept by the graph: every path of that series has +/// it after the graph is drawn again, the other series stay opaque, 100% forgets it, and undo takes it back. +#[test] +fn an_item_opacity_on_a_series_mark_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let opacities = |s: &Session, index: u32| -> Vec<(f32, f32)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.appearance.fill().unwrap().opacity, m.appearance.stroke().unwrap().opacity)).collect() + }; + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap(); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), Some(0.6))); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap(); + assert_eq!(restored, spec(&s, id)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(opacities(&s, 0).iter().all(|o| *o == (0.4, 0.6)), "{:?}", opacities(&s, 0)); + assert!(opacities(&s, 1).iter().all(|o| *o == (1.0, 1.0)), "{:?}", opacities(&s, 1)); + // Back to 100%: the graph keeps no opacity for the fill. + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); + s.execute("graph.setType", &json!({"type": "bar"})).unwrap(); + assert!(opacities(&s, 0).iter().all(|o| *o == (1.0, 0.6)), "{:?}", opacities(&s, 0)); + // Undo goes back through the type change, the 100% and the data edit, then takes the stroke opacity away with its + // edit. + for _ in 0..4 { + s.execute("edit.undo", &json!({})).unwrap(); + } + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.stroke_opacity), (Some(0.4), None)); +} + +/// Clear Appearance and Reduce to Basic Appearance on a series mark take its fill and stroke opacity away, and the +/// graph keeps none, so the series stays opaque when it is drawn again. +#[test] +fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() { + for cmd in ["appearance.clear", "appearance.reduceToBasic"] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap(); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 1, "opacity": 60})).unwrap(); + s.execute(cmd, &json!({"ids": [bar.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.stroke_opacity), (None, None), "{cmd}"); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 0).children().unwrap().iter().all(|m| m.appearance.fill().unwrap().opacity == 1.0), "{cmd}"); + } +} + +/// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque +/// (a blend mode alone) keeps nothing for its series. +#[test] +fn removing_a_series_marks_fill_forgets_its_kept_opacity() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty()); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); + s.execute("appearance.removeItem", &json!({"ids": [bar.0], "index": 0})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); + // An opaque fill added over the kept one takes it away too. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, None); +} + +/// An opacity set on a row added to a graph design's copy inside a series is not kept for the series: the graph draws +/// the design again from the design itself. +#[test] +fn an_opacity_on_a_design_copy_in_a_series_is_not_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("appearance.addFill", &json!({"ids": [copy.id.0]})).unwrap(); + s.execute("transparency.set", &json!({"ids": [copy.id.0], "item": 0, "opacity": 40})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none())); +} + +/// A series' kept opacity goes on its generated marks only: a column design made of one path keeps its own opacity. +#[test] +fn a_column_design_keeps_its_own_opacity_under_a_series_opacity() { + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let mut look = vectorcraft_doc::Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0); + look.fill_mut().unwrap().opacity = 0.5; + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let art = vectorcraft_doc::Node::path(d.alloc_id(), rect, look); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Square".into(), art: std::sync::Arc::new(art) }); + let options = ColumnDesign { design: "Square".into(), column_type: ColumnType::Vertical, ..ColumnDesign::default() }; + let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(0.2), ..Default::default() }]; + let g = GraphSpec { + rows: vec![vec![35.0]], + axis_min: Some(0.0), + axis_max: Some(50.0), + series_columns: vec![Some(options)], + series_paints: paints, + ..GraphSpec::default() + }; + let out = super::generate(&mut d, &g); + let group = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap(); + let mut fills = vec![]; + group.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. }) { + fills.push(n.appearance.fill().unwrap().opacity); + } + }); + assert!(!fills.is_empty() && fills.iter().all(|o| *o == 0.5), "{fills:?}"); +} + +/// An opacity set on an item that the graph does not draw again (a second fill added to a mark) is not kept. +#[test] +fn an_opacity_on_an_added_item_of_a_series_mark_is_not_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let items = s.doc().unwrap().doc.node(bar).unwrap().appearance.items.clone(); + let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap(); + assert!(items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap() != lower); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none())); +} + +/// The fill positions (lowest, topmost) in a mark's appearance stack. +fn fill_rows(s: &Session, mark: NodeId) -> (usize, usize) { + let items = s.doc().unwrap().doc.node(mark).unwrap().appearance.items.clone(); + let lower = items.iter().position(vectorcraft_doc::AppearanceItem::is_fill).unwrap(); + let upper = items.iter().rposition(vectorcraft_doc::AppearanceItem::is_fill).unwrap(); + assert!(lower < upper); + (lower, upper) +} + +/// Moving a translucent fill to the top of a series mark's stack, or placing a copy of it there, makes the graph keep +/// its opacity. +#[test] +fn moving_or_duplicating_a_series_marks_fill_to_the_top_keeps_its_opacity() { + for cmd in ["appearance.moveItem", "appearance.duplicateItem"] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("appearance.addFill", &json!({"ids": [bar.0]})).unwrap(); + let (lower, upper) = fill_rows(&s, bar); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": lower, "opacity": 40})).unwrap(); + assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.fill_opacity.is_none()), "{cmd}"); + let p = if cmd == "appearance.moveItem" { + json!({"ids": [bar.0], "from": lower, "to": upper}) + } else { + json!({"ids": [bar.0], "index": lower, "to": upper + 1}) + }; + s.execute(cmd, &p).unwrap(); + let top = fill_rows(&s, bar).1; + assert_eq!(s.doc().unwrap().doc.node(bar).unwrap().appearance.items[top].opacity(), 0.4, "{cmd}"); + assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4), "{cmd}"); + } +} + +/// Moving or copying a translucent mark's appearance onto a mark of another series (the Layers panel's target +/// circle, or the Eyedropper) makes the graph keep the opacity for that series; a move leaves the source series with +/// none. +#[test] +fn moving_or_copying_an_appearance_onto_a_series_mark_keeps_its_opacity() { + for (cmd, source_keeps) in [("move", None), ("copy", Some(0.4)), ("eyedropper", Some(0.4))] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "column"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (from, to) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); + s.execute("transparency.set", &json!({"ids": [from.0], "item": 0, "opacity": 40})).unwrap(); + match cmd { + "eyedropper" => s.execute("appearance.copyFrom", &json!({"source": from.0, "ids": [to.0]})).unwrap(), + _ => s.execute("appearance.transfer", &json!({"source": from.0, "target": to.0, "copy": cmd == "copy"})).unwrap(), + }; + let paints = spec(&s, id).series_paints; + assert_eq!(paints.get(1).and_then(|p| p.fill_opacity), Some(0.4), "{cmd}"); + assert_eq!(paints[0].fill_opacity, source_keeps, "{cmd}"); + } +} + +/// A line graph keeps a stroke opacity set on a series' line, and puts it on the line drawn again. +#[test] +fn a_stroke_opacity_on_a_series_line_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = graph(&mut s, "line"); + let lines = |s: &Session| -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, 0).children().unwrap().to_vec(); + marks.iter().filter(|m| m.appearance.stroke().is_some_and(|st| st.width > 0.0)).map(|m| (**m).clone()).collect() + }; + let line = lines(&s).first().unwrap().clone(); + let row = line.appearance.items.iter().rposition(|it| !it.is_fill()).unwrap(); + s.execute("transparency.set", &json!({"ids": [line.id.0], "item": row, "opacity": 60})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.6)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4], [2, 5]]})).unwrap(); + let after = lines(&s); + assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); +} + +/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. +#[test] +fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let paints = vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(-1.0), stroke_opacity: Some(f32::NAN), ..Default::default() }]; + let paints = [paints, vec![vectorcraft_doc::SeriesPaint { fill_opacity: Some(40.0), ..Default::default() }]].concat(); + let g = GraphSpec { rows: vec![vec![35.0, 20.0]], axis_min: Some(0.0), axis_max: Some(50.0), series_paints: paints, ..GraphSpec::default() }; + let out = super::generate(&mut d, &g); + for (index, want) in [(0, 0.0), (1, 1.0)] { + let group = out.iter().find(|n| n.extras().series_index == Some(index)).unwrap(); + let marks: Vec<_> = group.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert!(!marks.is_empty()); + for m in marks { + assert_eq!(m.appearance.fill().unwrap().opacity, want); + assert!(m.appearance.stroke().is_none_or(|st| st.opacity == 1.0)); + } + } +} + /// A series index read from a file past the graph's series is not a series: painting its art /// paints the art alone and stores nothing (no allocation sized by the file). #[test] diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 875721b923..0c0a81ed90 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -439,7 +439,6 @@ pub const UNCHECKED: &[&str] = &[ "shape.flare", "shape.polarGrid", "shape.rectangularGrid", - "stroke.set", "stroke.setAdvanced", "swatch.edit", "swatch.library.load", diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 76f8b82ffd..9bc52c89b1 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -338,8 +338,9 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { Cow::Owned(doc) => doc, }; let mut need: Vec = vec![]; + let mut seen = std::collections::HashSet::new(); doc.visit_placed(|_, p| { - if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { + if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && seen.insert(p.key.clone()) { need.push(p.clone()); } }); diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 03b927cd39..3b1289a862 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index ed2671eafd..8e929f14d2 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -13,6 +13,7 @@ use serde_json::{Value, json}; use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LIBRARIES, builtin_library}; use vectorcraft_color::palette_io::{self, PaletteFormat}; use vectorcraft_color::recolor::Palette; +use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches}; use vectorcraft_doc::pattern::pattern_paint; @@ -587,8 +588,9 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result { /// ungrouped): a group's name brings the group, a swatch's name the swatch alone; no names, all. fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result, &'a Swatch)>> { let mut out: Vec<(Option<&str>, &Swatch)> = vec![]; + let mut seen: HashSet<&str> = HashSet::new(); let mut push = |g: Option<&'a str>, w: &'a Swatch| { - if !out.iter().any(|(_, x)| x.name == w.name) { + if seen.insert(w.name.as_str()) { out.push((g, w)); } }; @@ -606,6 +608,23 @@ fn picked<'a>(lib: &'a SwatchLibrary, names: &[String], cmd: &str) -> Result HashSet { + let names = d.swatches_iter().map(|w| &w.name).chain(d.swatch_groups.iter().map(|g| &g.name)); + names.cloned().chain([REGISTRATION.to_string()]).collect() +} + +/// [`vectorcraft_doc::Document::free_swatch_name`] against `taken`, which takes the name returned. +fn free_in(taken: &mut HashSet, base: &str) -> String { + let name = if taken.contains(base) { + (2..).map(|i| format!("{base} {i}")).find(|n| !taken.contains(n)).unwrap_or_else(|| base.to_string()) + } else { + base.to_string() + }; + taken.insert(name.clone()); + name +} + fn add(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.library.add"; let (info, lib) = library_param(s, p, C)?; @@ -621,11 +640,21 @@ fn add(s: &mut Session, p: &Value) -> Result { // same pattern) are reused, not added again. let patterns = patternlib::library_doc(&info.id); let d = &s.doc()?.doc; + // The document's swatches by name (the first of a name wins, as in `Document::swatch`), looked + // up once so that a large palette takes linear time. + let mut by_name: std::collections::HashMap<&str, &Swatch> = std::collections::HashMap::new(); + for w in d.swatches_iter() { + by_name.entry(w.name.as_str()).or_insert(w); + } let there = |w: &Swatch| match (&w.paint, &patterns) { (Paint::Pattern { pattern, .. }, Some(src)) => { patternlib::existing_pattern(d, src, pattern).and_then(|target| patternlib::pattern_swatch(d, &target)) } - _ => d.swatch(&w.name).is_some_and(|x| x.paint == w.paint).then(|| w.name.clone()), + _ => match by_name.get(w.name.as_str()) { + Some(x) => x.paint == w.paint, + None => w.name == REGISTRATION && d.swatch(&w.name).is_some_and(|x| x.paint == w.paint), + } + .then(|| w.name.clone()), }; // Library name → the name of the document's swatch. let mut existing: Vec<(String, String)> = vec![]; @@ -647,6 +676,7 @@ fn add(s: &mut Session, p: &Value) -> Result { // already had, or one made here. let before = d.swatch_groups.len(); let mut placed: Vec<(&str, usize)> = vec![]; + let mut taken = taken_names(d); for (group, w) in fresh { if let (Paint::Pattern { pattern, .. }, Some(src)) = (&w.paint, &patterns) { // The pattern comes with its swatch; a pattern the document has without one @@ -661,9 +691,10 @@ fn add(s: &mut Session, p: &Value) -> Result { } }; out.push((w.name.clone(), name)); + taken = taken_names(d); continue; } - let name = d.free_swatch_name(&w.name); + let name = free_in(&mut taken, &w.name); let sw = Swatch { name: name.clone(), ..(*w).clone() }; match group { Some(g) => { @@ -671,7 +702,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let i = match found.or_else(|| d.swatch_groups.iter().take(before).position(|x| x.name == *g)) { Some(i) => i, None => { - let name = d.free_swatch_name(g); + let name = free_in(&mut taken, g); d.swatch_groups.push(SwatchGroup { name, swatches: vec![] }); d.swatch_groups.len() - 1 } diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 90bb32bbbb..19da4c8ab0 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -335,6 +335,21 @@ fn character_style_color_and_features_are_checked() { assert_eq!(s.doc().unwrap().history.undo.len(), undo); } +#[test] +fn text_style_rejects_doubly_signed_feature_tags() { + let (mut s, id) = session_with_text("Text"); + let before = runs(&s, id); + let undo = s.doc().unwrap().history.undo.len(); + for cmd in ["text.setStyle", "text.setRangeStyle"] { + for tag in ["--liga", "+-dnom", "-+ss01", "++liga"] { + let error = s.execute(cmd, &json!({"id": id, "start": 0, "end": 4, "features": [tag]})).unwrap_err().to_string(); + assert!(error.contains(&format!("`{tag}`")), "{cmd}: {error}"); + } + } + assert_eq!(runs(&s, id), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + #[test] fn style_paints_count_as_swatch_uses_and_gradient_stops_are_checked() { let (mut s, _) = session_with_text("Text"); diff --git a/crates/engine/src/cmd/webexport.rs b/crates/engine/src/cmd/webexport.rs index c67d9428fa..af0f313ea1 100644 --- a/crates/engine/src/cmd/webexport.rs +++ b/crates/engine/src/cmd/webexport.rs @@ -21,7 +21,7 @@ use vectorcraft_render::encode::quantize::{self, Dither, Indexed, PaletteOptions use vectorcraft_render::encode::web::{self, ColorSort}; use vectorcraft_render::encode::{gif, jpeg, png}; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, f64_or, has_doc, str_param, unique_name}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, f64_or, has_doc, str_param, unique_name_from}; const C: &str = "document.exportForWeb"; @@ -866,6 +866,8 @@ pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem: placed.push((None, Some(name))); } else { let mut taken = HashSet::new(); + // Per base, where the suffix search resumes, so many slices of one name don't each count up from 2. + let mut next = std::collections::HashMap::new(); for c in &cells { let kind = c.area.id.and_then(|id| doc.slice_options(id)).map(|o| o.kind).unwrap_or_default(); if kind != SliceKind::Image { @@ -875,7 +877,7 @@ pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem: let base: String = doc.slice_name(c.area).chars().map(|ch| if ch.is_alphanumeric() || "-_.".contains(ch) { ch } else { '-' }).collect(); // A slice named after a Windows device (`aux`, `NUL`…) gets a `-` after the device name. let file = |base: &str| super::fileio::safe_file_name(&format!("{base}.{ext}")); - let base = unique_name(&base, |n| taken.contains(&file(n).to_lowercase())); + let base = unique_name_from(&base, &mut next, |n| taken.contains(&file(n).to_lowercase())); taken.insert(file(&base).to_lowercase()); let name = format!("{IMAGES}/{}", file(&base)); let [x0, y0, x1, y1] = c.px; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2a9fb16289..14db3a2cdb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -206,6 +206,8 @@ pub struct Session { /// The id and persistent options of the tool when the press under way began, which a dropped /// press puts back ([`Session::drop_tool_press`]). pub(crate) press_options: Option<(String, serde_json::Map)>, + /// The engine saves the selection and active layer before a Type press and restores them if it drops. + pub(crate) press_type_selection: Option<(Selection, Option)>, /// The freeform gradient point selected once the tool took the press under way, which a /// dropped press selects again ([`Session::drop_tool_press`]). pub(crate) press_point: Option>, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ba6ee2f1fe..6e462e029c 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -93,6 +93,7 @@ impl Session { plane_widget_press: false, pointer_held: false, press_options: None, + press_type_selection: None, press_point: None, ruler_guide: None, port_marks: Default::default(), @@ -142,6 +143,7 @@ impl Session { if self.active.is_none() { return; } + self.press_type_selection = None; let view = self.last_view; // The switch goes on whatever the lent tool's last actions did, as for the deactivation. let _ = self.give_back_tool(view); @@ -158,6 +160,12 @@ impl Session { self.tool_set_since_reset = false; } + /// Finish the tool's pending work and give it fresh state, as a switch to another document + /// does, while the current document stays the same (a switch to another view of it). + pub fn reset_tool(&mut self) { + self.reset_tool_for_doc_switch(); + } + pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { if self.active != Some(index) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1f1c2599d..63855cccb6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5,6 +5,7 @@ use vectorcraft_geom::Point; use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; pub(super) mod adjust; +mod ai_open_path; mod appearance; mod areafit; mod assets; diff --git a/crates/engine/src/tests/ai_open_path.rs b/crates/engine/src/tests/ai_open_path.rs new file mode 100644 index 0000000000..b344a465cb --- /dev/null +++ b/crates/engine/src/tests/ai_open_path.rs @@ -0,0 +1,58 @@ +//! An open path in a PDF-compatible `.ai` opens as drawn (#2920). The PDF reader took a five-point +//! open polyline whose points reach every corner of its own bounding box for a rectangle, so the +//! zigzag below opened as a closed box. The file is written here from ISO 32000; it has no +//! Illustrator editing data, so it opens from its PDF content as such an `.ai` does. + +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::SubPath; +use vectorcraft_testkit::pdf::{PdfPage, pdf}; + +use crate::*; + +/// A 2 pt black stroke, no fill: a "W" whose middle peak rises only halfway. Its ends and the +/// points beside them are the four corners of its bounding box; the peak is on none of them. +const ZIGZAG: &str = "0 0 0 RG 2 w 80 38 m 80 21.75 l 60.571 29.875 l 40 21.75 l 40 38 l S"; + +fn subpaths(name: &str, content: &str) -> Vec { + let bytes = pdf(&[PdfPage::new(120.0, 60.0, content)], None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + let mut out = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + out.extend(path.subpaths.iter().cloned()); + } + }); + out +} + +#[test] +fn an_open_zigzag_in_an_ai_file_opens_open_with_its_five_points() { + let paths = subpaths("zigzag.ai", ZIGZAG); + assert_eq!(paths.len(), 1, "{paths:?}"); + let zigzag = &paths[0]; + assert!(!zigzag.closed, "the open path was closed: {zigzag:?}"); + assert_eq!(zigzag.anchors.len(), 5, "{zigzag:?}"); + let xs: Vec = zigzag.anchors.iter().map(|a| a.p.x).collect(); + for (got, want) in xs.iter().zip([80.0, 80.0, 60.571, 40.0, 40.0]) { + assert!((got - want).abs() < 1e-3, "anchors at x {xs:?}"); + } + // The peak is halfway up, not on the bounding box's edge. + let ys: Vec = zigzag.anchors.iter().map(|a| a.p.y).collect(); + assert!((ys[2] - (ys[1] + ys[0]) / 2.0).abs() < 1e-3, "anchors at y {ys:?}"); +} + +#[test] +fn the_same_zigzag_in_a_pdf_opens_open_too() { + let paths = subpaths("zigzag.pdf", ZIGZAG); + assert_eq!(paths.iter().map(|p| (p.closed, p.anchors.len())).collect::>(), [(false, 5)]); +} + +#[test] +fn a_rectangle_written_as_lines_still_opens_as_a_closed_rectangle() { + let paths = subpaths("box.ai", "0 0 0 RG 2 w 40 21.75 m 80 21.75 l 80 38 l 40 38 l h S"); + assert_eq!(paths.len(), 1, "{paths:?}"); + assert!(paths[0].closed); + assert_eq!(paths[0].anchors.len(), 4, "{paths:?}"); +} diff --git a/crates/engine/src/tests/legacy_ai.rs b/crates/engine/src/tests/legacy_ai.rs index 90c728a2fc..70218cf76a 100644 --- a/crates/engine/src/tests/legacy_ai.rs +++ b/crates/engine/src/tests/legacy_ai.rs @@ -93,6 +93,31 @@ fn the_default_stays_pdf_compatible_and_other_versions_are_refused() { assert_eq!(v["options"]["aiVersion"]["value"], "pdf", "{v}"); } +#[test] +fn errors_name_the_legacy_ai_not_the_pdf_compatible_one() { + let mut s = three_boards(); + for (cmd, p) in [ + ("file.saveCopy", json!({"format": "ai", "aiVersion": "7", "artboard": "2"})), + ("document.export", json!({"format": "ai", "aiVersion": "7", "range": 2})), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("legacy .ai (version 7) options: invalid type") && !e.contains("PDF-compatible"), "{cmd}: {e}"); + } + // Version 7 writes a legacy .ai whatever pdfCompatible says, and its errors and notes name it. + let e = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "pdfCompatible": false, "artboard": "2"})).unwrap_err().to_string(); + assert!(e.contains("legacy .ai (version 7) options: invalid type"), "{e}"); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "artboard": 1, "version": 3})).unwrap(); + assert!(r["warnings"].to_string().contains("`version` was not used: legacy .ai (version 7) files don't read it"), "{r}"); + // Several artboards are several files, which one encoded file can't hold. + let d = &s.doc().unwrap().doc; + let e = crate::cmd::fileio::encode_with_warnings(d, "ai", &json!({"aiVersion": "7"})).unwrap_err().to_string(); + assert!(e.contains("legacy .ai (version 7) writes one file per artboard here") && !e.contains("PDF-compatible"), "{e}"); + assert!(crate::cmd::fileio::encode_with_warnings(d, "ai", &json!({"aiVersion": "7", "artboard": 2})).is_ok()); + // The PDF-compatible file keeps its name. + let e = s.execute("document.export", &json!({"format": "ai", "includeLinked": "yes"})).unwrap_err().to_string(); + assert!(e.contains("PDF-compatible .ai options: invalid type") && !e.contains("legacy"), "{e}"); +} + #[test] fn transparency_and_appearances_are_written_as_plain_art_and_type_as_text() { let mut s = Session::new(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 0c351dba7d..18ffe69cb9 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1948,6 +1948,53 @@ fn select_same_font_size_and_fill() { assert!(s.execute("select.same.fontSize", &json!({})).is_err()); } +/// The font Same commands take their reference from the first selected type that can be selected: +/// type that is hidden or locked inside a selected group, on a template layer, or outside the +/// isolated group is passed over. +#[test] +fn select_same_font_skips_type_that_cannot_be_selected() { + let mut s = session(); + let typed = + |s: &mut Session, y: f64, font: &str| id_of(&s.execute("text.create", &json!({"x": 0, "y": y, "text": "type", "font": font})).unwrap()); + let hid = typed(&mut s, 20.0, "Arial"); + let courier = typed(&mut s, 60.0, "Courier"); + let arial = typed(&mut s, 100.0, "Arial"); + let other_courier = typed(&mut s, 140.0, "Courier"); + sel(&mut s, &[hid, courier]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let l2 = id_of(&s.execute("layer.new", &json!({"top": true})).unwrap()); + let on_template = typed(&mut s, 180.0, "Courier"); + s.execute("layer.setProps", &json!({"id": l2.0, "template": true})).unwrap(); + // Hidden or locked type inside the selected group is not the reference. + for (on, off) in [ + (json!({"id": hid.0, "visible": false}), json!({"id": hid.0, "visible": true})), + (json!({"id": hid.0, "locked": true}), json!({"id": hid.0, "locked": false})), + ] { + s.execute("layer.setProps", &on).unwrap(); + sel(&mut s, &[g]); + assert_eq!(s.execute("select.same.fontFamily", &json!({})).unwrap()["count"], 2, "{on}"); + assert_eq!(selected(&s), vec![courier, other_courier], "{on}"); + s.execute("layer.setProps", &off).unwrap(); + } + // Type on a template layer, selected by id, is not the reference. + sel(&mut s, &[on_template, arial]); + s.execute("select.same.fontFamily", &json!({})).unwrap(); + assert_eq!(selected(&s), vec![hid, arial]); + // Type outside the isolated group is not the reference. + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + sel(&mut s, &[arial, courier]); + s.execute("select.same.fontFamily", &json!({})).unwrap(); + assert_eq!(selected(&s), vec![courier]); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // A selection whose only type cannot be selected has no reference. + for id in [hid, courier] { + s.execute("layer.setProps", &json!({"id": id.0, "visible": false})).unwrap(); + } + sel(&mut s, &[g]); + assert!(s.execute("select.same.fontFamily", &json!({})).is_err()); + assert_eq!(selected(&s), vec![g]); +} + /// Select › Same › Text Fill & Stroke Color selects the type with characters whose fill and stroke /// both match the first character of the selected type: the same fill with another stroke, or the /// same stroke with another fill, is left out, and in type with several runs one run has to match diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index a1f8e00cc4..e989597ae5 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -74,6 +74,27 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { assert!(check_keys("x", "{a, …as file.new}", &json!({"b": 1})).is_err()); } +/// `stroke.set` names every key it reads (its own, `item`, and `ids`/`id`/`target`), so a +/// misnamed one is refused instead of answering `objects: 1` with nothing changed. +#[test] +fn stroke_set_refuses_an_unknown_key() { + assert!(!UNCHECKED.contains(&"stroke.set")); + let e = refusal("stroke.set", json!({"ids": [7], "width": 0.5})); + assert!(e.starts_with("unknown parameter `width`") && e.contains("weight"), "{e}"); + for ok in [ + json!({"ids": [7], "weight": 0.5}), + json!({"id": 7, "weight": 1, "cap": "round", "join": "bevel", "miterLimit": 4, "align": "inside"}), + json!({"dash": [6, 3], "dashOffset": 1, "alignDashes": true, "startArrow": "Arrow", "endArrow": null, "arrowAlign": "tip"}), + json!({"profile": "lens", "item": null, "target": "contents"}), + // `item_target` reads `aligned` for an explicit item. + json!({"item": 1, "aligned": true, "weight": 2}), + // What document.inspect reports in strokeOptions reads back unchanged. + json!({"widthPoints": null, "gradientMode": "within"}), + ] { + assert!(check_params("stroke.set", &ok).is_ok(), "{ok}"); + } +} + /// A command that takes the keys of one that isn't checked yet isn't checked either. #[test] fn a_reference_to_an_unchecked_command_is_unchecked() { diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 08071df059..243dda722a 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -290,6 +290,30 @@ fn ai_export_public_aliases_preserve_missing_placed_links() { } } +/// The `.ai` editing copy for other apps draws a missing placed file as its preview, with the +/// pages or without them, and says so. +#[test] +fn ai_editing_copy_warns_about_missing_placed_files() { + let dir = Folder::new("placed-ai-editing-copy-missing"); + let src = dir.file("logo.vectorcraft"); + star_source(&src, "placed-ai-editing-copy-missing-unique"); + let mut s = session(); + place(&mut s, &src); + let parent = dir.file("parent.vectorcraft"); + save(&mut s, &parent); + std::fs::remove_file(&src).unwrap(); + open(&mut s, &parent); + let doc = &s.doc().unwrap().doc; + let (_, missing) = cmd::place::document::full_documents(doc); + assert!(!missing.is_empty()); + for compatible in [true, false] { + let enc = cmd::fileio::encode_all(doc, "ai", &json!({"pdfCompatible": compatible})).unwrap(); + for w in &missing { + assert!(enc.warnings.contains(w), "pdfCompatible {compatible}: {w:?} not in {:?}", enc.warnings); + } + } +} + /// Include Linked Files must retain relinkable objects when only their previews are available. #[test] fn native_included_links_keep_missing_placed_documents_editable() { diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index d52cbba610..130a7da18d 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -196,7 +196,11 @@ fn ai_without_pdf_content_and_compression() { }; let (full, _) = ai(&mut s, json!({})); let (blank, warnings) = ai(&mut s, json!({"pdfCompatible": false})); - assert!(warnings.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("without PDF content")), "{warnings}"); + let says = |w: &Value, text: &str| w.as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains(text)); + assert!(says(&warnings, "without PDF content") && says(&warnings, "apps that read its editing data open it"), "{warnings}"); + // Without the editing copy, other apps show empty pages. + let (_, plain) = ai(&mut s, json!({"pdfCompatible": false, "editingData": false})); + assert!(says(&plain, "VectorCraft opens it as before") && !says(&plain, "editing data"), "{plain}"); // The pages are empty (an import of a page finds no art), the document comes back whole. let page = |s: &mut Session, b: &[u8]| { s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(b), "pages": "1"})).unwrap(); @@ -213,6 +217,33 @@ fn ai_without_pdf_content_and_compression() { assert!(loose.len() > packed.len(), "{} vs {}", loose.len(), packed.len()); } +#[test] +fn ai_option_errors_name_a_file_saved_without_pdf_content() { + let mut s = three_boards(); + for p in + [json!({"format": "ai", "pdfCompatible": false, "artboard": "2"}), json!({"format": "ai", "pdfCompatible": false, "includeLinked": "yes"})] + { + let e = s.execute("file.saveCopy", &p).unwrap_err().to_string(); + assert!(e.contains("non-PDF-compatible .ai options: invalid type"), "{p}: {e}"); + } + let e = s.execute("document.export", &json!({"format": "ai", "pdfCompatible": false, "artboard": "2"})).unwrap_err().to_string(); + assert!(e.contains("`document.export`") && e.contains("non-PDF-compatible .ai options: invalid type"), "{e}"); + let p = json!({"format": "ait", "pdfCompatible": false, "includeLinked": "yes"}); + let e = s.execute("file.saveAsTemplate", &p).unwrap_err().to_string(); + assert!(e.contains("non-PDF-compatible .ait template options: invalid type"), "{e}"); + // A template saved without PDF content is written. + let r = s.execute("file.saveAsTemplate", &json!({"format": "ait", "pdfCompatible": false})).unwrap(); + assert!(r["warnings"].to_string().contains("without PDF content"), "{r}"); + // So are the notes on native options the file doesn't read. + let r = s.execute("file.saveCopy", &json!({"format": "ai", "pdfCompatible": false, "version": 3})).unwrap(); + assert!(r["warnings"].to_string().contains("`version` was not used: non-PDF-compatible .ai files don't read it"), "{r}"); + // A PDF-compatible file keeps its name. + let e = s.execute("file.saveCopy", &json!({"format": "ai", "includeLinked": "yes"})).unwrap_err().to_string(); + assert!(e.contains("PDF-compatible .ai options: invalid type") && !e.contains("non-PDF"), "{e}"); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "version": 3})).unwrap(); + assert!(r["warnings"].to_string().contains("`version` was not used: PDF-compatible .ai files don't read it"), "{r}"); +} + #[test] fn a_remembered_artboard_the_document_no_longer_has_is_dropped() { let dir = Folder::new("remembered-artboards"); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 60243f343e..30d0ff7f80 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -889,6 +889,137 @@ fn type_tool_typing_is_one_undo_step() { assert_eq!(obj(&s, id).plain_text(), ""); } +#[test] +fn dropped_type_press_restores_the_original_text_and_selection() { + let mut s = session(); + s.execute("guide.add", &json!({"vertical": true, "pos": 500})).unwrap(); + let first = text(&mut s, "First text"); + let second = NodeId(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Second text", "size": 20})).unwrap()["id"].as_u64().unwrap()); + let at = |s: &Session, id: NodeId| { + let target = obj(s, id); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &target); + let (a, b) = vectorcraft_text::caret_position(&layout, 3); + target.xf * a.midpoint(b) + }; + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + let a = at(&s, first); + click(&mut s, a.x + 0.1, a.y); + assert!(s.tool_wants_text()); + s.select(|_, selection| selection.guides.push(0)).unwrap(); + let before = s.doc().unwrap().selection.clone(); + let b = at(&s, second); + s.pointer(&PointerEvent::new(PointerKind::Down, b.x + 0.1, b.y), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![second]); + s.drop_tool_press(v).unwrap(); + assert_eq!(s.doc().unwrap().selection, before); + assert!(s.tool_wants_text()); + s.tool_text("!", v).unwrap(); + assert!(obj(&s, first).plain_text().contains('!')); + assert_eq!(obj(&s, second).plain_text(), "Second text"); +} + +#[test] +fn dropped_type_press_restores_the_active_layer_without_a_prior_selection() { + let mut s = session(); + let original_layer = s.doc().unwrap().doc.layers[0].id; + let other_layer = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap()); + let text_id = text(&mut s, "Other layer text"); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.active_mut().unwrap().active_layer = Some(original_layer); + let target = obj(&s, text_id); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &target); + let (a, b) = vectorcraft_text::caret_position(&layout, 3); + let at = target.xf * a.midpoint(b); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, at.x + 0.1, at.y), v).unwrap(); + assert_eq!(s.doc().unwrap().active_layer, Some(other_layer)); + s.drop_tool_press(v).unwrap(); + assert!(s.doc().unwrap().selection.objects.is_empty()); + assert_eq!(s.doc().unwrap().active_layer, Some(original_layer)); +} + +#[test] +fn dropped_type_press_after_document_switch_keeps_the_new_document_selection() { + let mut s = session(); + let first = text(&mut s, "First document"); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let second = text(&mut s, "Second document"); + let selected = s.doc().unwrap().selection.clone(); + let active_layer = s.doc().unwrap().active_layer; + assert_eq!(selected.objects, vec![second]); + + assert!(s.set_active(0)); + let target = obj(&s, first); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &target); + let (a, b) = vectorcraft_text::caret_position(&layout, 3); + let at = target.xf * a.midpoint(b); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, at.x + 0.1, at.y), v).unwrap(); + + assert!(s.set_active(1)); + s.drop_tool_press(v).unwrap(); + assert_eq!(s.doc().unwrap().selection, selected); + assert_eq!(s.doc().unwrap().active_layer, active_layer); +} + +#[test] +fn dropped_type_press_keeps_new_text_selected_on_a_locked_artboard() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let second = text(&mut s, "Second text"); + let first = NodeId(s.execute("text.create", &json!({"x": 1050, "y": 100, "text": "First text", "size": 20})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().selection.objects, vec![first]); + assert!(s.doc().unwrap().selection.new_art.contains(&first)); + let at = |s: &Session, id: NodeId| { + let target = obj(s, id); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &target); + let (a, b) = vectorcraft_text::caret_position(&layout, 3); + target.xf * a.midpoint(b) + }; + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + let a = at(&s, first); + click(&mut s, a.x + 0.1, a.y); + let before = s.doc().unwrap().selection.clone(); + let b = at(&s, second); + s.pointer(&PointerEvent::new(PointerKind::Down, b.x + 0.1, b.y), v).unwrap(); + s.drop_tool_press(v).unwrap(); + assert_eq!(s.doc().unwrap().selection, before); + assert!(s.tool_wants_text()); +} + +#[test] +fn dropped_lent_selection_press_keeps_type_editing() { + let mut s = session(); + let first = text(&mut s, "First text"); + let second = NodeId(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Second text", "size": 20})).unwrap()["id"].as_u64().unwrap()); + let point = |s: &Session, id: NodeId| { + let target = obj(s, id); + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &target); + let (a, b) = vectorcraft_text::caret_position(&layout, 3); + target.xf * a.midpoint(b) + }; + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + let a = point(&s, first); + click(&mut s, a.x + 0.1, a.y); + assert!(s.tool_wants_text()); + let b = point(&s, second); + let cmd = Mods { cmd: true, ..Mods::default() }; + s.pointer(&PointerEvent::new(PointerKind::Down, b.x + 0.1, b.y).with_mods(cmd), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![second]); + s.drop_tool_press(v).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![first]); + assert!(s.tool_wants_text()); + s.tool_text("!", v).unwrap(); + assert!(obj(&s, first).plain_text().contains('!')); + assert_eq!(obj(&s, second).plain_text(), "Second text"); +} + #[test] fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 4eb1912294..ee83e72d68 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -366,16 +366,32 @@ impl Session { /// Escape, as during a drag, for most tools), and a release returns the tool to rest. Of what /// the tool asks for along the way, only a cancel and a notify run, so a marquee selects /// nothing and a click opens no size dialog. The tool gets back the persistent options it had - /// when the press began, and none of the press's changes to them are stored. The freeform - /// gradient point selected once the tool took the press is selected again. + /// when the press began, and none of the press's changes to them are stored. A dropped Type + /// press restores the prior selection. The freeform gradient point selected once the tool + /// took the press is selected again. pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> { // Nothing to roll back without a document; the tool still drops the press and gets the release. let _ = self.cancel_interaction(); let before = self.press_options.take(); + let type_selection = self.press_type_selection.take(); let point = self.press_point.take(); let mut acts = self.with_tool_cx(view, |t, cx| t.drop_press(cx)); acts.retain(|a| matches!(a, Action::Cancel | Action::Notify(_))); let dropped = self.take_tool_panic().and_then(|()| self.apply_actions(acts)); + if let Some((selected, layer)) = type_selection { + if self.active().is_some_and(|st| st.selection != selected) { + if let Some(st) = self.active_mut() { + st.selection = selected.clone(); + } + self.select(|_, selection| *selection = selected)?; + } + if let Some(st) = self.active_mut() + && st.active_layer != layer + { + st.active_layer = layer; + st.layer_rows.clear(); + } + } let up = PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), backquote: false, pressure: 1.0, pen: None }; let r = dropped.and(self.pointer_event(&up, view, false)); // A drag out of the object previews the point's removal, which deselects it. @@ -413,8 +429,19 @@ impl Session { PointerKind::DoubleClick => {} } match ev.kind { - PointerKind::Down => self.press_options = Some((self.tool.id().to_string(), Self::persistent_options(self.tool.as_ref()))), - PointerKind::Up => self.press_options = None, + PointerKind::Down => { + self.press_options = Some((self.tool.id().to_string(), Self::persistent_options(self.tool.as_ref()))); + self.press_type_selection = + if matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") { + self.active().map(|st| (st.selection.clone(), st.active_layer)) + } else { + None + }; + } + PointerKind::Up => { + self.press_options = None; + self.press_type_selection = None; + } _ => {} } // The Plane Switching Widget takes its clicks whatever the tool. diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 6743c2a2a4..0004a65d31 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -13,6 +13,8 @@ vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } +# The editing data for other apps writes clipped art cut to its clipping path. +vectorcraft-pathops = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } # Windows metafile previews, opened when a file's PostScript can't be read. diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs new file mode 100644 index 0000000000..bdcefb56b4 --- /dev/null +++ b/crates/eps/src/ai_editing.rs @@ -0,0 +1,380 @@ +//! The editing copy of the art that a PDF-compatible `.ai` file carries in its pages' +//! `PieceInfo` ([`vectorcraft_pdf::PrivateData`]): the document's layers, sublayers, groups, paths, +//! colors, swatches and artboards in the form [`crate::import`]'s `.ai` reader reads, so that apps +//! that open `.ai` files from that copy get them as editable objects. +//! +//! The form is the one the published specification documents (Adobe Illustrator File Format +//! Specification, version 7.0, see [`crate::legacy_ai`]) for paths (§5.3), colors (§5.4), +//! groups and compound paths (§5.3.5), guides (§6) and layers (§9), with the newer forms the reader +//! reads from files users sent: +//! +//! - Header comments: `%%Creator` (naming the format's app, see [`CREATOR_TEXT`]), `%%For` (the +//! document's author), `%%Canvassize`, `%%BoundingBox`, +//! `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, `%AI7_ImageSettings`, the first artboard as +//! `%AI3_Cropmarks` and `%AI3_TileBox`, the canvas's center as `%AI3_TemplateBox`, +//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI24_LargeCanvasScale` (1, or 10 when the +//! artboards together don't fit on the canvas: the art, boxes and artboards are then written at +//! 1/10 of their size), `%AI9_ColorModel` (1 RGB, 2 CMYK), +//! `%AI5_ArtFlags`, `%AI5_TargetResolution`, `%AI5_NumLayers`, `%AI5_OpenViewLayers` (a digit for +//! each layer, 7 shown and 6 hidden), `%AI7_GridSettings` (§2.1.1), and the version comment +//! `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). +//! - Setup: `0 Bn` (no gradient definitions, §3), the color swatches (`%AI5_BeginPalette`, +//! `0 0 Pb`, the Registration swatch, each swatch's color operator and `(name) Pc`, a color group as `0 (name) count Pg` +//! before its swatches, `PB`), then the artboards as the `ArtboardArray` of the `/Document` +//! dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on `%_` +//! lines. +//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0 +//! dimPercent 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the +//! index -1), `(name) Ln`, the layer's name as an XML id in an `/ArtDictionary`, its art and +//! sublayers, `LB`, `%AI5_EndLayer--`. +//! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color +//! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a +//! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint +//! Xs`. Upper case paints the stroke. +//! - State before each object: locked `A`, hidden `Xw` (`1` or `0`). An object's name follows it as +//! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are `(N) *`, the +//! path, `(N) *` (`(n) *` when closed). +//! +//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at +//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the +//! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and +//! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such +//! loss a warning: type as its outlines and its inline graphics' art, gradients as their average color, pattern fills as their +//! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping +//! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with +//! their average color, symbols and live objects as their art (symbol art past +//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw +//! as their filled outlines, several fills or strokes as a group of a path for each, gradient and +//! pattern swatches, transparency and live effects other than geometry effects left out. +//! +//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with +//! the version numbers of the newest files users sent ([`private_data`]). + +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_doc::{Document, Node, Unit}; +use vectorcraft_geom::{Affine, Rect}; +use vectorcraft_pdf::PrivateData; + +use crate::ps; + +mod writer; + +use writer::Writer; + +/// The version comment's value: the creator version of the newest files users sent. +pub const CREATOR_VERSION: &str = "30.7.0"; +/// The `PieceInfo` numbers that go with Zstandard-compressed data: container 12, creator 30, +/// round trip 24, stream type 2. +const CONTAINER_VERSION: u32 = 12; +const CREATOR: u32 = 30; +const ROUNDTRIP_VERSION: u32 = 24; +const ROUNDTRIP_STREAM_TYPE: u32 = 2; +/// What starts the data streams of Zstandard-compressed data. +const ZSTD_MARKER: &[u8] = b"%AI24_ZStandard_Data"; +/// `%%Canvassize`: the side of the canvas in points, as the files users sent have it. +const CANVAS_SIZE: u32 = 16383; +/// The scale of a large canvas: its sides are ten times the canvas's, as the format's large canvas +/// documents have them (2270 inches square). +const LARGE_CANVAS_SCALE: f64 = 10.0; +/// `%AI5_ArtSize`, as the files users sent have it. +const ART_SIZE: u32 = 14400; +/// `%AI5_ArtFlags` (§2.1.1, Table 4), as the files users sent have them: page setup, print tiles, +/// placed images, preview patterns, split long paths, tile full pages, printer's default +/// screens, auto default screens, compatible gradient printing. +const ART_FLAGS: &str = "0 0 0 1 0 0 1 0 0"; +/// `%AI5_TargetResolution` (§2.1.1). +const TARGET_RESOLUTION: u32 = 800; +/// The gridlines' gray: VectorCraft's default grid color (200 of 255). +const GRID_GRAY: f64 = 200.0 / 255.0; + +/// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names +/// VectorCraft and the format's app, and holds no version number; the version is written on its +/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]). +const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes + +/// The first line of a PostScript file (the DSC version comment). +const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment + +/// What to write in the header. +#[derive(Clone, Debug, Default)] +pub struct AiEditingOptions { + /// `%%Title`. + pub title: String, + /// `%%CreationDate`, Unix seconds. + pub created: Option, +} + +/// The editing data and what it couldn't hold. +#[derive(Clone, Debug, Default)] +pub struct AiEditingOutput { + /// The program: header comments, setup, layers, trailer. + pub program: Vec, + /// Its header comments, up to `%%EndComments`. + pub header: Vec, + pub warnings: Vec, +} + +/// Write `doc` as the editing data of a `.ai` file: every artboard and layer. +pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result { + let board = doc.artboards.first().ok_or("the document has no artboard")?.rect; + if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) { + return Err("the document has no artboard".into()); + } + // Live geometry effects as their result, as the PDF part draws them. + let baked = baked(doc); + let doc = baked.as_ref().unwrap_or(doc); + // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. + let scale = canvas_scale(doc); + let reduced = (scale != 1.0).then(|| { + let mut d = doc.clone(); + crate::import::scale_document(&mut d, 1.0 / scale, true); + // Symbol and pattern art is placed without scaling its strokes, so its strokes and effects + // are scaled here. + for s in d.symbols.iter_mut() { + scale_strokes(Arc::make_mut(&mut s.art), 1.0 / scale); + } + for p in d.patterns.iter_mut() { + for a in &mut p.art { + scale_strokes(Arc::make_mut(a), 1.0 / scale); + } + } + d + }); + let doc = reduced.as_ref().unwrap_or(doc); + let board = doc.artboards.first().map_or(board, |a| a.rect); + // The document is y down; art space is ruler space: y up, its origin the first artboard's + // top-left corner. + let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y0]); + let mut w = Writer::new(doc, to_art); + for layer in &doc.layers { + w.layer(layer); + } + let first = to_art.transform_rect_bbox(board); + let art = doc + .layers + .iter() + .filter(|l| l.visible) + .filter_map(|l| l.visual_bounds()) + .map(|b| to_art.transform_rect_bbox(b)) + .filter(|b| b.is_finite()) + .reduce(|a, b| a.union(b)) + .unwrap_or(first); + let origin = ruler_origin(doc, to_art); + let header = header(doc, o, &w, art, first, origin, scale); + let mut s = header.clone(); + // No gradient definitions: gradients are written as their average color. + s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n0 Bn\n"); + w.palette(&mut s); + artboards(&mut s, doc, to_art, origin); + s.push_str("%%EndSetup\n"); + s.push_str(&w.body); + s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"); + // Lines end in CR LF, as in the files users sent; line breaks inside strings are escaped. + Ok(AiEditingOutput { program: crlf(&s), header: crlf(&header), warnings: w.warnings }) +} + +/// `s` with each line ending in CR LF. +fn crlf(s: &str) -> Vec { + s.replace('\n', "\r\n").into_bytes() +} + +/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]), +/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are +/// written as); `None` when nothing changes. +fn baked(doc: &Document) -> Option { + let baked = vectorcraft_effects::bake_document(doc); + let d = baked.as_ref().unwrap_or(doc); + let needs = vectorcraft_effects::needs_bake; + if !d.symbols.iter().any(|s| needs(&s.art)) && !d.patterns.iter().any(|p| p.art.iter().any(|a| needs(a))) { + return baked; + } + // The symbols' art, then each pattern's art, baked as the layers of a copy of the document. + let mut art = d.clone(); + let symbols = d.symbols.iter().map(|s| Arc::new(Node::group(s.art.id, vec![s.art.clone()]))); + let patterns = d.patterns.iter().map(|p| Arc::new(Node::group(vectorcraft_doc::NodeId(0), p.art.clone()))); + art.layers = symbols.chain(patterns).collect(); + // The copy keeps what baking adds to the document (the images color adjustments make). + let Some(mut out) = vectorcraft_effects::bake_document(&art) else { return baked }; + let layers = std::mem::replace(&mut out.layers, d.layers.clone()); + let mut members = layers.iter().map(|l| l.children().cloned().unwrap_or_default()); + for s in out.symbols.iter_mut() { + if let Some(a) = members.next().and_then(|m| m.first().cloned()) { + s.art = a; + } + } + for p in out.patterns.iter_mut() { + if let Some(m) = members.next() { + p.art = m; + } + } + Some(out) +} + +/// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with +/// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers +/// that go with that form; and what it couldn't hold. +pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData, Vec), String> { + let out = export_ai_editing(doc, o)?; + let mut data = ZSTD_MARKER.to_vec(); + data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)); + let p = PrivateData { + data, + metadata: out.header, + container_version: CONTAINER_VERSION, + creator_version: CREATOR, + roundtrip_version: ROUNDTRIP_VERSION, + roundtrip_stream_type: ROUNDTRIP_STREAM_TYPE, + }; + Ok((p, out.warnings)) +} + +/// Where art space's origin lies on the canvas (y down, [`CANVAS_SIZE`] points square): the +/// artboards together centered on the canvas's center, on whole points, as the files users sent +/// place them. +fn ruler_origin(doc: &Document, to_art: Affine) -> (f64, f64) { + let all = doc.artboards.iter().map(|ab| to_art.transform_rect_bbox(ab.rect)).filter(|r| r.is_finite()).reduce(|a, b| a.union(b)); + let c = all.map_or((0.0, 0.0), |r| (r.center().x, r.center().y)); + let mid = canvas_center(); + ((mid - c.0).floor(), (mid + c.1).floor()) +} + +/// `%AI24_LargeCanvasScale`: 1 when the artboards together fit on the canvas, with a point to +/// spare on each side for the rounding of [`ruler_origin`], else [`LARGE_CANVAS_SCALE`]: then the +/// art, boxes and artboards are written at 1/[`LARGE_CANVAS_SCALE`] of their size. +fn canvas_scale(doc: &Document) -> f64 { + let all = doc.artboards.iter().map(|ab| ab.rect).filter(|r| r.is_finite()).reduce(|a, b| a.union(b)); + let room = f64::from(CANVAS_SIZE) - 2.0; + if all.is_some_and(|r| r.width() > room || r.height() > room) { LARGE_CANVAS_SCALE } else { 1.0 } +} + +/// The strokes and effects of `n` and of what it holds, `k` times as large. +fn scale_strokes(n: &mut Node, k: f64) { + n.appearance.scale_strokes(k); + n.appearance.scale_effects(k, vectorcraft_effects::scale_effect); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + scale_strokes(Arc::make_mut(&mut mask.art), k); + } + if let Some(children) = n.children_mut() { + for c in children { + scale_strokes(Arc::make_mut(c), k); + } + } +} + +/// The canvas's center, on its own axes and in both directions. +fn canvas_center() -> f64 { + f64::from(CANVAS_SIZE) / 2.0 +} + +/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `first` the first +/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]); `scale` +/// the large canvas scale ([`canvas_scale`]). +fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64), scale: f64) -> String { + let mut s = String::new(); + s.push_str(PS_HEADER); + s.push('\n'); + let _ = writeln!(s, "%%Creator: {CREATOR_TEXT}"); + let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}"); + // The user and the organization (§2.1.1). + let _ = writeln!(s, "%%For: {} ()", ps::bytes(doc.metadata.author.as_bytes())); + let _ = writeln!(s, "%%Title: {}", ps::bytes(o.title.as_bytes())); + if let Some(t) = o.created { + let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t); + let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})"); + } + let _ = writeln!(s, "%%Canvassize: {CANVAS_SIZE}"); + let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64); + s.push_str("%%HiResBoundingBox: "); + ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]); + s.pop(); + s.push('\n'); + s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n"); + s.push_str("%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n"); + // Linked images aren't embedded (the data holds no images). + s.push_str("%AI7_ImageSettings: 0\n"); + let spots = w.spot_swatches(); + for (i, (name, _)) in spots.iter().enumerate() { + let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::bytes(name.as_bytes())); + } + for (i, (name, cmyk)) in spots.iter().enumerate() { + s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " }); + ps::push_nums(&mut s, &cmyk.map(f64::from)); + let _ = writeln!(s, "{}", ps::bytes(name.as_bytes())); + } + // The first artboard, left, bottom, right, top. + let mut board = String::new(); + ps::push_nums(&mut board, &[first.x0, first.y0, first.x1, first.y1]); + board.pop(); + let _ = writeln!(s, "%AI3_Cropmarks: {board}"); + // The canvas's center in art space. + let (cx, cy) = (ps::num(canvas_center() - origin.0), ps::num(origin.1 - canvas_center())); + let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}"); + let _ = writeln!(s, "%AI3_TileBox: {board}"); + s.push_str("%AI3_DocumentPreview: None\n"); + let _ = writeln!(s, "%AI5_ArtSize: {ART_SIZE} {ART_SIZE}"); + let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units)); + let _ = writeln!(s, "%AI24_LargeCanvasScale: {}", ps::num(scale)); + let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 }; + let _ = writeln!(s, "%AI9_ColorModel: {model}"); + let _ = writeln!(s, "%AI5_ArtFlags: {ART_FLAGS}"); + let _ = writeln!(s, "%AI5_TargetResolution: {TARGET_RESOLUTION}"); + let _ = writeln!(s, "%AI5_NumLayers: {}", doc.layers.len()); + // A digit for each layer: 7 shown, 6 hidden. + let views: String = doc.layers.iter().map(|l| if l.visible { '7' } else { '6' }).collect(); + if !views.is_empty() { + let _ = writeln!(s, "%AI5_OpenViewLayers: {views}"); + } + // Gridlines and subdivisions (§2.1.1, Table 6): in back, lines, in VectorCraft's grid gray. + let (space, subdiv) = (ps::num(doc.grid.spacing), doc.grid.subdivisions); + let gray = ps::num(GRID_GRAY); + let _ = writeln!(s, "%AI7_GridSettings: {space} {subdiv} {space} {subdiv} 1 0 {gray} {gray} {gray} {gray} {gray} {gray}"); + s.push_str("%%EndComments\n"); + s +} + +/// `%AI5_RulerUnits` of `u` (as the reader reads it; units it has no number for are points). +fn ruler_units(u: Unit) -> u8 { + match u { + Unit::Inches => 0, + Unit::Millimeters => 1, + Unit::Picas => 3, + Unit::Centimeters => 4, + Unit::Pixels => 6, + Unit::Points | Unit::FeetInches | Unit::Meters | Unit::Yards | Unit::Feet | Unit::Q | Unit::Ha => 2, + } +} + +/// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and +/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on +/// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio +/// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the +/// first) and its name; then the canvas's size. Every record holds all of these entries. The +/// artboards and the canvas size are written in the `/Recorded` part of the `/Document` +/// dictionary, followed by an empty `/NotRecorded` part. +fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) { + s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n"); + for (i, ab) in doc.artboards.iter().enumerate() { + let r = to_art.transform_rect_bbox(ab.rect); + s.push_str("%_/Dictionary :\n%_"); + ps::push_nums(s, &[r.x0, r.y1]); + s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_"); + ps::push_nums(s, &[r.x1, r.y0]); + s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_"); + ps::push_nums(s, &[origin.0 + r.x0, origin.1 - r.y1]); + s.push_str("/RealPoint\n%_ (RulerOrigin) ,\n%_"); + let par = ab.video_ruler_pixel_aspect_ratio; + ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 }); + // Display marks: 1 the center mark, 2 cross hairs, 4 video safe areas. + let mark = u8::from(ab.show_center_mark) | u8::from(ab.show_cross_hairs) << 1 | u8::from(ab.show_video_safe_areas) << 2; + let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0)); + let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes())); + } + s.push_str("%_; (ArtboardArray) ,\n"); + let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,"); + s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"); +} + +#[cfg(test)] +mod tests; diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs new file mode 100644 index 0000000000..5a355fe3df --- /dev/null +++ b/crates/eps/src/ai_editing/tests.rs @@ -0,0 +1,1031 @@ +//! The editing data written, then read back by VectorCraft's own reader ([`crate::ai_alone`]). + +use std::sync::Arc; + +use vectorcraft_color::swatch::{Swatch, SwatchGroup}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeKind, + StrokeLayer, Symbol, +}; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; + +use super::*; + +fn opts() -> AiEditingOptions { + AiEditingOptions { title: "Sample".into(), created: Some(0) } +} + +/// The program written from `doc`, as text with its CR LF line ends read as LF. +fn program(doc: &Document) -> String { + String::from_utf8(export_ai_editing(doc, &opts()).unwrap().program).unwrap().replace("\r\n", "\n") +} + +/// `doc` written, then read back from the private data alone. +fn read_back(doc: &Document) -> Document { + let (p, _) = private_data(doc, &opts()).unwrap(); + crate::ai_alone(&p.data).unwrap().0 +} + +fn solid(c: Color) -> Paint { + Paint::solid(c) +} + +/// A swatch's color at `tint`, linked to it. +fn linked(name: &str, full: Color, tint: f32) -> Paint { + Paint::Solid { color: full.tinted(tint), swatch: Some(name.into()), tint } +} + +const BRAND: Color = Color::Cmyk { c: 0.1, m: 0.7, y: 0.2, k: 0.05 }; +const GOLD: Color = Color::Cmyk { c: 0.0, m: 0.25, y: 0.9, k: 0.1 }; +const SKY: Color = Color::Rgb { r: 0.25, g: 0.5, b: 1.0 }; +const MINT: Color = Color::Lab { l: 80.0, a: -40.0, b: 10.0 }; + +/// A 200 × 100 document with a second artboard: swatches (process, global process, CMYK, RGB and +/// Lab spot colors, a color group); a layer `Back` (preset color 2) holding a named group `Pair` +/// of a named rectangle `Red` (stroked 2 pt, round caps, bevel joins, miter limit 4, dashed, over +/// a gray fill that overprints) and a hidden, locked one, an even-odd ellipse in a global color at +/// half tint, an open path stroked in a spot color, a guide, and a hidden locked sublayer `Sub` +/// (a custom color, not printing, dimmed) holding a path filled with an RGB and stroked with a Lab +/// spot color; a second layer `Front`, locked, with a CMYK square named like the rectangle. +fn sample() -> Document { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + second.id = 2; + second.name = "Back side".into(); + second.rect = Rect::new(250.0, 0.0, 450.0, 100.0); + doc.artboards.push(second); + let swatch = |name: &str, c: Color, global: bool, spot: bool| Swatch { name: name.into(), paint: solid(c), global, spot }; + doc.swatches = vec![ + Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, + swatch("Teal", Color::rgb(0.0, 0.5, 0.5), false, false), + swatch("Brand", BRAND, true, false), + swatch("Gold", GOLD, true, true), + swatch("Sky", SKY, true, true), + swatch("Mint", MINT, true, true), + ] + .into(); + doc.swatch_groups = vec![SwatchGroup { name: "Grays".into(), swatches: vec![swatch("Mid", Color::gray(0.5), false, false)] }].into(); + doc.layers.clear(); + let back = doc.add_layer(Some("Back")); + let front = doc.add_layer(Some("Front")); + + let mut stroke = StrokeLayer::new(solid(Color::rgb(0.0, 0.0, 1.0)), 2.0); + stroke.cap = LineCap::Round; + stroke.join = LineJoin::Bevel; + stroke.miter_limit = 4.0; + stroke.dash = Some(Dash { pattern: vec![3.0, 2.0], offset: 1.0, align_corners: false }); + let fill = FillLayer { overprint: true, ..FillLayer::new(solid(Color::gray(0.25))) }; + let mut red = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), + Appearance { items: vec![AppearanceItem::Fill(fill), AppearanceItem::Stroke(stroke)], ..Appearance::default() }, + ); + red.name = Some("Red".into()); + let mut hidden = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), + Appearance::basic(solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), Paint::None, 0.0), + ); + hidden.visible = false; + hidden.locked = true; + let mut pair = Node::group(doc.alloc_id(), vec![Arc::new(red), Arc::new(hidden)]); + pair.name = Some("Pair".into()); + doc.insert(Some(back), usize::MAX, pair).unwrap(); + + let ellipse = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::ellipse(Rect::new(100.0, 10.0, 160.0, 50.0)), + rule: FillRule::EvenOdd, + live: None, + clipping: false, + guide: false, + }, + ); + let ellipse = Node { appearance: Appearance::basic(linked("Brand", BRAND, 0.5), Paint::None, 0.0).into(), ..ellipse }; + doc.insert(Some(back), usize::MAX, ellipse).unwrap(); + let mut open = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((10.0, 60.0).into()), + kurbo::PathEl::LineTo((60.0, 90.0).into()), + ])); + open.subpaths.iter_mut().for_each(|sp| sp.closed = false); + let line = Node::path(doc.alloc_id(), open, Appearance::basic(Paint::None, linked("Gold", GOLD, 1.0), 3.0)); + doc.insert(Some(back), usize::MAX, line).unwrap(); + let guide = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(0.0, 50.0, 200.0, 50.0)), + rule: FillRule::NonZero, + live: None, + clipping: false, + guide: true, + }, + ); + doc.insert(Some(back), usize::MAX, guide).unwrap(); + + let mut sub = Node::layer(doc.alloc_id(), "Sub", LayerColor::Custom([10, 200, 30])); + if let NodeKind::Layer { printable, dim_images, .. } = &mut sub.kind { + *printable = false; + *dim_images = Some(70); + } + sub.visible = false; + sub.locked = true; + let sub = doc.insert(Some(back), usize::MAX, sub).unwrap(); + let skyfill = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(120.0, 60.0, 190.0, 95.0)), + Appearance::basic(linked("Sky", SKY, 0.8), linked("Mint", MINT, 1.0), 1.0), + ); + doc.insert(Some(sub), usize::MAX, skyfill).unwrap(); + + let mut square = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(300.0, 20.0, 340.0, 60.0)), + Appearance::basic(solid(Color::cmyk(0.5, 0.0, 1.0, 0.0)), Paint::None, 0.0), + ); + square.name = Some("Red".into()); + doc.insert(Some(front), usize::MAX, square).unwrap(); + if let Some(l) = doc.layers.get_mut(1) { + let l = Arc::make_mut(l); + l.locked = true; + if let NodeKind::Layer { color, .. } = &mut l.kind { + *color = LayerColor::Preset(2); + } + } + doc +} + +/// `v` to three decimals. +fn r3(v: f64) -> String { + format!("{:.3}", (v * 1000.0).round() / 1000.0 + 0.0) +} + +fn color(c: &Color) -> String { + let f = |v: f32| r3(f64::from(v)); + match *c { + Color::Gray { k } => format!("gray {}", f(k)), + Color::Cmyk { c, m, y, k } => format!("cmyk {} {} {} {}", f(c), f(m), f(y), f(k)), + Color::Rgb { r, g, b } => format!("rgb {} {} {}", f(r), f(g), f(b)), + Color::Lab { l, a, b } => format!("lab {} {} {}", f(l), f(a), f(b)), + } +} + +fn paint(p: &Paint) -> String { + match p { + Paint::Solid { color: c, swatch, tint } => format!("{} {swatch:?} {}", color(c), r3(f64::from(*tint))), + Paint::None => "none".into(), + other => format!("{other:?}"), + } +} + +/// What the reader's view of the slice's constructs is made of, one line per object. +fn view(nodes: &[Arc], depth: usize, out: &mut Vec) { + for n in nodes { + let head = format!("{depth} {:?} shown={} locked={}", n.name, n.visible, n.locked); + match &n.kind { + NodeKind::Layer { color, printable, preview, dim_images, clip, children, .. } => { + out.push(format!("{head} layer {color:?} printable={printable} preview={preview} dim={dim_images:?} clip={clip}")); + view(children, depth + 1, out); + } + NodeKind::Group { children, clip } => { + out.push(format!("{head} group clip={clip}")); + view(children, depth + 1, out); + } + NodeKind::Path { path, rule, guide, .. } => { + let mut s = format!("{head} path {rule:?} guide={guide}"); + for sp in &path.subpaths { + s += &format!(" [closed={}", sp.closed); + // Points are written to four decimals. + let r2 = |v: f64| format!("{:.2}", v + 0.0); + for a in &sp.anchors { + s += &format!(" {},{} {},{} {},{}", r2(a.h_in.x), r2(a.h_in.y), r2(a.p.x), r2(a.p.y), r2(a.h_out.x), r2(a.h_out.y)); + } + s += "]"; + } + if !guide { + // Items that paint nothing aren't written. + for it in n.appearance.items.iter().filter(|i| !i.paint().is_none()) { + s += &match it { + AppearanceItem::Fill(f) => format!(" fill({} op={})", paint(&f.paint), f.overprint), + AppearanceItem::Stroke(st) => format!( + " stroke({} w={} {:?} {:?} m={} {:?} op={})", + paint(&st.paint), + r3(st.width), + st.cap, + st.join, + r3(st.miter_limit), + st.dash.as_ref().map(|d| (d.pattern.clone(), d.offset)), + st.overprint + ), + }; + } + } + out.push(s); + } + other => out.push(format!("{head} {other:?}")), + } + } +} + +fn lines(doc: &Document) -> Vec { + let mut out = vec![]; + view(&doc.layers, 0, &mut out); + out +} + +fn swatches(doc: &Document) -> Vec { + let one = |s: &Swatch| format!("{} global={} spot={} {}", s.name, s.global, s.spot, paint(&s.paint)); + let mut out: Vec = doc.swatches.iter().filter(|s| !matches!(s.paint, Paint::None)).map(one).collect(); + for g in doc.swatch_groups.iter() { + out.push(format!("group {}", g.name)); + out.extend(g.swatches.iter().map(one)); + } + out +} + +#[test] +fn the_reader_reads_back_every_construct_of_the_slice() { + let doc = sample(); + let back = read_back(&doc); + assert_eq!(lines(&back), lines(&doc)); + assert_eq!(swatches(&back), swatches(&doc)); + let boards: Vec<(String, Rect)> = back.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect(); + assert_eq!( + boards, + vec![("Artboard 1".to_string(), Rect::new(0.0, 0.0, 200.0, 100.0)), ("Back side".to_string(), Rect::new(250.0, 0.0, 450.0, 100.0))] + ); + assert_eq!(back.color_mode, doc.color_mode); + assert_eq!(back.units, doc.units); +} + +#[test] +fn writing_again_gives_the_same_bytes() { + let doc = sample(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + // What the reader reads back is written as the same program. + let read = export_ai_editing(&read_back(&doc), &opts()).unwrap(); + assert_eq!(read.program, first.program); + assert!(first.warnings.is_empty(), "{:?}", first.warnings); +} + +#[test] +fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { + let doc = sample(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let (p, _) = private_data(&doc, &opts()).unwrap(); + assert!(p.data.starts_with(ZSTD_MARKER)); + let mut body = p.data.get(ZSTD_MARKER.len()..).unwrap(); + let mut decoded = vec![]; + std::io::Read::read_to_end(&mut ruzstd::decoding::StreamingDecoder::new(&mut body).unwrap(), &mut decoded).unwrap(); + assert_eq!(decoded, out.program); + assert!(out.program.starts_with(&out.header)); + assert_eq!(p.metadata, out.header); + let header = String::from_utf8(out.header).unwrap(); + assert!(header.ends_with("%%EndComments\r\n")); + let header = header.replace("\r\n", "\n"); + for line in [ + "%%AI8_CreatorVersion: 30.7.0\n%%For: () ()\n%%Title: (Sample)\n", + "%%Canvassize: 16383\n", + "%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n%AI7_ImageSettings: 0\n%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n", + "%AI3_Cropmarks: 0 -100 200 0\n%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n%AI3_TileBox: 0 -100 200 0\n", + "%AI5_ArtSize: 14400 14400\n", + "%AI9_ColorModel: 1\n%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0\n%AI5_TargetResolution: 800\n%AI5_NumLayers: 2\n", + "%AI5_OpenViewLayers: 77\n%AI7_GridSettings: 72 8 72 8 1 0 0.7843 0.7843 0.7843 0.7843 0.7843 0.7843\n%%EndComments\n", + ] { + assert!(header.contains(line), "{line}\n{header}"); + } + assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2)); +} + +/// Art space is ruler space: y up from the first artboard's top-left corner. The artboards +/// together sit centered on the canvas: each one's top-left corner on the canvas (y down) is its +/// `RulerOrigin`, and the template box holds the canvas's center in art space. +#[test] +fn art_lies_on_its_artboard_in_ruler_space() { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + (second.id, second.name, second.rect) = (2, "Back".into(), Rect::new(250.0, 0.0, 450.0, 100.0)); + doc.artboards.push(second); + let rect = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)), + Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, rect).unwrap(); + let text = program(&doc); + for line in [ + "%%BoundingBox: 10 -40 30 -20\n", + "%AI3_Cropmarks: 0 -100 200 0\n", + "%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n", + "\n10 -20 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_200 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7966 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_1 /Bool (IsArtboardSelected) ,\n", + "%_250 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_450 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_8216 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_0 /Bool (IsArtboardSelected) ,\n", + "%_16383 /Int (AIDocumentCanvasSize) ,\n", + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + // The creator names the format's app, before the canvas size and the template box, and holds + // no version number (the version is written in `%%AI8_CreatorVersion`). + let creator = text.lines().find_map(|l| l.strip_prefix("%%Creator: ")).unwrap(); + assert!(creator.contains("Illustrator"), "{creator}"); + assert!(!creator.contains("88") && !creator.chars().any(|c| c.is_ascii_digit()), "{creator}"); + assert!(text.find("%%Creator: ").unwrap() < text.find("%%Canvassize: 16383").unwrap()); + assert!(text.find("%%Canvassize: 16383").unwrap() < text.find("%AI3_TemplateBox").unwrap()); + // The reader puts the art back where it was, on its artboard. + let read = read_back(&doc); + assert_eq!(read.artboards.iter().map(|a| a.rect).collect::>(), doc.artboards.iter().map(|a| a.rect).collect::>()); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(10.0, 20.0, 30.0, 40.0)); +} + +/// A gray rectangle at `r` (document space) in `doc`'s first layer. +fn add_rect(doc: &mut Document, r: Rect) { + let rect = Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, rect).unwrap(); +} + +/// The end of the document data: the artboards and the canvas size in the `/Recorded` part of the +/// `/Document` dictionary, then an empty `/NotRecorded` part. +const RECORDED: &str = "%_; (ArtboardArray) ,\n%_16383 /Int (AIDocumentCanvasSize) ,\n%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n"; + +/// One letter-size artboard: the art 36 points in from its top-left corner, the artboard's record +/// and the boxes, with the artboards in the `/Recorded` part of the `/Document` dictionary. +#[test] +fn one_artboard_is_recorded_with_its_art_in_place() { + let mut doc = Document::new(612.0, 792.0); + doc.artboards[0].name = "Page".into(); + add_rect(&mut doc, Rect::new(36.0, 36.0, 576.0, 756.0)); + let text = program(&doc); + for line in [ + "%%BoundingBox: 36 -756 576 -36\n", + "%AI3_Cropmarks: 0 -792 612 0\n", + "%AI3_TemplateBox: 306.5 -396.5 306.5 -396.5\n", + "\n36 -36 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_612 -792 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7885 7795 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_(Page) /UnicodeString (Name) ,\n%_; ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + let read = read_back(&doc); + assert_eq!(read.artboards.iter().map(|a| (a.name.as_str(), a.rect)).collect::>(), [("Page", Rect::new(0.0, 0.0, 612.0, 792.0))]); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(36.0, 36.0, 576.0, 756.0)); +} + +/// Two named artboards of different sizes, tops aligned: each record holds its corners in art +/// space and its top-left corner on the canvas, where the two together are centered. +#[test] +fn artboards_of_different_sizes_keep_their_names_and_places() { + let mut doc = Document::new(400.0, 300.0); + doc.artboards[0].name = "Front".into(); + let mut back = doc.artboards[0].clone(); + (back.id, back.name, back.rect) = (2, "Back".into(), Rect::new(440.0, 0.0, 740.0, 500.0)); + doc.artboards.push(back); + add_rect(&mut doc, Rect::new(50.0, 50.0, 150.0, 100.0)); + add_rect(&mut doc, Rect::new(490.0, 50.0, 590.0, 100.0)); + let text = program(&doc); + for line in [ + "%AI3_Cropmarks: 0 -300 400 0\n", + "%AI3_TemplateBox: 370.5 -250.5 370.5 -250.5\n", + "\n50 -50 m\n", + "\n490 -50 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_400 -300 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7821 7941 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_1 /Bool (IsArtboardSelected) ,\n%_(Front) /UnicodeString (Name) ,\n", + "%_440 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_740 -500 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_8261 7941 /RealPoint\n%_ (RulerOrigin) ,\n", + "%_0 /Bool (IsArtboardSelected) ,\n%_(Back) /UnicodeString (Name) ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + let read = read_back(&doc); + assert_eq!( + read.artboards.iter().map(|a| (a.name.as_str(), a.rect)).collect::>(), + [("Front", Rect::new(0.0, 0.0, 400.0, 300.0)), ("Back", Rect::new(440.0, 0.0, 740.0, 500.0))] + ); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert_eq!(bounds, Rect::new(50.0, 50.0, 590.0, 100.0)); +} + +/// An artboard larger than the canvas (20,000 points square): the data is written on a large +/// canvas, at 1/10 of the size, so the artboard lies on the canvas; read beside a page of the +/// artboard's size in points, it comes back at its size. +#[test] +fn an_artboard_larger_than_the_canvas_is_written_on_a_large_canvas() { + let mut doc = Document::new(20_000.0, 20_000.0); + add_rect(&mut doc, Rect::new(1000.0, 1000.0, 3000.0, 2000.0)); + let text = program(&doc); + for line in [ + "%AI24_LargeCanvasScale: 10\n", + "%AI3_Cropmarks: 0 -2000 2000 0\n", + "\n100 -100 m\n", + "%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_2000 -2000 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7191 7191 /RealPoint\n%_ (RulerOrigin) ,\n", + RECORDED, + ] { + assert!(text.contains(line), "{line}\n{text}"); + } + // A page of the artboard's size in points, as a PDF part in larger units gives it. + let mut page = Document::new(20_000.0, 20_000.0); + add_rect(&mut page, Rect::new(1000.0, 1000.0, 3000.0, 2000.0)); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let (read, notes, layers) = crate::layered_ai(&p.data, page, vec![], false, true, 1.389); + assert!(layers, "{notes:?}"); + assert_eq!(read.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}"); + let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.y1 - 2000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}"); + // The same without a readable PDF part, on its first page's size. + let (alone, notes) = crate::ai_alone_on_page(&p.data, (20_000.0, 20_000.0), 1.389).unwrap(); + assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}"); + // And from the editing data alone, as a file saved without its PDF part opens. + let alone = read_back(&doc); + assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)]); + let bounds = alone.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap(); + assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}"); + + // A symbol's 30-point stroke is written at 1/10 too, as the layers' strokes are. + let mut doc = Document::new(20_000.0, 20_000.0); + let line = shapes::rectangle(Rect::new(0.0, 0.0, 500.0, 500.0)); + let art = Node::path(doc.alloc_id(), line, Appearance::basic(Paint::None, solid(Color::gray(0.5)), 30.0)); + doc.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((1000.0, 1000.0)) }); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let (read, notes, layers) = crate::layered_ai(&p.data, Document::new(20_000.0, 20_000.0), vec![], false, true, 1.389); + assert!(layers, "{notes:?}"); + let mut all = vec![]; + paths(read.layers[0].children().unwrap(), &mut all); + let widths: Vec = all.iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect(); + assert!(!widths.is_empty() && widths.iter().all(|w| (w - 30.0).abs() < 1e-6), "{widths:?}"); + + // Artboards that fit stay on the canvas at their size. + assert!(program(&Document::new(16_000.0, 16_000.0)).contains("%AI24_LargeCanvasScale: 1\n")); +} + +#[test] +fn a_pdf_carries_the_data_on_its_first_page() { + let doc = sample(); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let pdf = + vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions { private_data: Some(p.clone()), ..vectorcraft_pdf::PdfOptions::uncompressed() }) + .unwrap(); + assert_eq!(vectorcraft_pdf::illustrator_data(&pdf, None).as_deref(), Some(&p.data[..])); + let (read, _) = crate::ai_alone(&vectorcraft_pdf::illustrator_data(&pdf, None).unwrap()).unwrap(); + assert_eq!(lines(&read), lines(&doc)); +} + +#[test] +fn names_are_xml_ids_numbered_when_taken() { + let text = program(&sample()); + assert!(text.contains("%_/XMLUID : (Red) ; (AI10_ArtUID) ,")); + assert!(text.contains("%_/XMLUID : (Red_00000000000000000001_) ; (AI10_ArtUID) ,")); + assert!(text.contains("%_/XMLUID : (Pair) ; (AI10_ArtUID) ,")); +} + +#[test] +fn many_objects_of_one_name_are_numbered_in_turn() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for _ in 0..20_000 { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default()); + n.name = Some("Dot".into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let text = program(&doc); + let ids: std::collections::HashSet<&str> = text.lines().filter(|l| l.starts_with("%_/XMLUID : (Dot")).collect(); + assert_eq!(ids.len(), 20_000); + assert!(text.contains("(Dot_00000000000000019999_)")); +} + +#[test] +fn layers_have_fourteen_operands() { + let text = program(&sample()); + assert!(text.contains("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Back) Ln\n"), "{text}"); + assert!(text.contains("%AI5_BeginLayer\n0 1 0 0 1 0 1 -1 10 200 30 0 70 0 Lb\n(Sub) Ln\n"), "{text}"); +} + +#[test] +fn layers_are_named_as_xml_ids_after_their_names() { + let mut doc = sample(); + let layer = doc.layers[0].id; + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default()); + n.name = Some("Back".into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let text = program(&doc); + assert!(text.contains("(Back) Ln\n%_/ArtDictionary :\n%_/XMLUID : (Back) ; (AI10_ArtUID) ,\n%_;\n%_\n"), "{text}"); + assert!(text.contains("(Sub) Ln\n%_/ArtDictionary :\n%_/XMLUID : (Sub) ; (AI10_ArtUID) ,\n"), "{text}"); + // An object named as a layer is numbered. + assert!(text.contains("%_/XMLUID : (Back_00000000000000000001_) ; (AI10_ArtUID) ,"), "{text}"); + // The reader keeps the names where they were. + let read = read_back(&doc); + assert_eq!(read.layers[0].name.as_deref(), Some("Back")); + let names: Vec> = read.layers[0].children().unwrap().iter().map(|c| c.name.as_deref()).collect(); + assert_eq!(names.last(), Some(&Some("Back"))); +} + +#[test] +fn lines_end_in_cr_lf() { + let mut doc = sample(); + doc.metadata.author = "A\nB".into(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let text = String::from_utf8(out.program).unwrap(); + assert_eq!(text.matches('\n').count(), text.matches("\r\n").count()); + assert_eq!(text.matches('\r').count(), text.matches("\r\n").count()); + // A line break in a string stays in the string, escaped. + assert!(text.contains("%%For: (A\\nB) ()\r\n"), "{text}"); +} + +#[test] +fn the_palette_starts_with_registration() { + let mut doc = sample(); + let rgb = program(&doc); + assert!(rgb.contains("0 0 Pb\n"), "{rgb}"); + let cell = rgb.split("0 0 Pb\n").nth(1).unwrap().lines().take(2).collect::>(); + assert!(cell[0].ends_with(" 0 0 0 ([Registration]) 0 1 Xz") && cell[0].split(' ').count() == 11, "{cell:?}"); + assert_eq!(cell[1], "([Registration]) Pc"); + doc.color_mode = vectorcraft_doc::ColorMode::Cmyk; + assert!(program(&doc).contains("0 0 Pb\n1 1 1 1 ([Registration]) 0 Xs\n([Registration]) Pc\n")); + // It's the built-in swatch: reading back adds no swatch. + assert_eq!(swatches(&read_back(&doc)), swatches(&doc)); +} + +#[test] +fn dotted_strokes_are_written_as_their_dots_with_a_warning() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut stroke = StrokeLayer::new(solid(Color::rgb(0.0, 0.0, 0.0)), 4.0); + stroke.cap = LineCap::Round; + stroke.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false }); + let line = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((10.0, 50.0).into()), + kurbo::PathEl::LineTo((60.0, 50.0).into()), + ])); + let n = Node::path(doc.alloc_id(), line, Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Appearance::default() }); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("dots")), "{:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("[0 10] 0 d"), "{text}"); + assert!(text.matches("\nf\n").count() >= 5, "a filled dot for each dash: {text}"); +} + +#[test] +fn hidden_layers_open_hidden() { + let mut doc = sample(); + let mut hidden = Node::layer(doc.alloc_id(), "Off", LayerColor::Preset(1)); + hidden.visible = false; + doc.layers.push(Arc::new(hidden)); + let text = program(&doc); + assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); +} + +/// A 200 × 100 document of what slice 1 writes as plain paths: a gradient fill, a clipping group, +/// a compound path, an image, type, a symbol instance and a half-transparent square. +fn unsupported() -> Document { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let fill = |p: Paint| Appearance::basic(p, Paint::None, 0.0); + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); + let shaded = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), fill(Paint::Gradient(Box::new(GradientPaint::new(g))))); + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(40.0, 10.0, 50.0, 30.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let clipped = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(35.0, 15.0, 60.0, 25.0)), + Appearance::basic(solid(Color::gray(0.5)), solid(Color::BLACK), 1.0), + ); + let mut window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true }); + window.name = Some("Window".into()); + doc.insert(Some(layer), usize::MAX, window).unwrap(); + let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 90.0, 30.0)), Appearance::default()); + let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(75.0, 15.0, 85.0, 25.0)), Appearance::default()); + let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + ring.appearance = fill(solid(Color::cmyk(0.0, 1.0, 1.0, 0.0))).into(); + doc.insert(Some(layer), usize::MAX, ring).unwrap(); + let mut png = vec![]; + let img = image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 255]).unwrap(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { + key, + width: 2, + height: 1, + xf: Affine::new([10.0, 0.0, 0.0, 10.0, 100.0, 10.0]), + link: None, + placement: Default::default(), + pixels: None, + }; + let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + doc.insert(Some(layer), usize::MAX, im).unwrap(); + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Type", + vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + ); + let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, t).unwrap(); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill(solid(Color::rgb(0.0, 1.0, 0.0)))); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((150.0, 10.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let mut faded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(170.0, 10.0, 190.0, 30.0)), fill(solid(Color::BLACK))); + faded.opacity = 0.5; + doc.insert(Some(layer), usize::MAX, faded).unwrap(); + doc +} + +/// Every path in `nodes` and their descendants. +fn paths(nodes: &[Arc], out: &mut Vec) { + for n in nodes { + match n.children() { + Some(c) => paths(c, out), + None => out.push((**n).clone()), + } + } +} + +#[test] +fn art_the_slice_does_not_cover_is_written_as_plain_paths() { + let doc = unsupported(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + for w in [writer::GRADIENTS, writer::CLIPPING, writer::IMAGES, writer::TYPE, writer::LIVE, writer::TRANSPARENCY] { + assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); + } + let back = read_back(&doc); + let layer = back.layers[0].children().unwrap(); + let kinds: Vec<&str> = layer + .iter() + .map(|n| match n.kind { + NodeKind::Path { .. } => "path", + NodeKind::Group { .. } => "group", + NodeKind::Compound { .. } => "compound", + _ => "other", + }) + .collect(); + assert_eq!(kinds, ["path", "group", "compound", "path", "group", "group", "path"]); + let mut all = vec![]; + paths(layer, &mut all); + assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); + // The gradient as its average color. + assert_eq!(paint(back.layers[0].children().unwrap()[0].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.5, 0.5)))); + // The clipping group: the gray fill and the black stroke's outline, cut to the 10 × 20 clip. + let window = &layer[1]; + assert_eq!(window.name.as_deref(), Some("Window")); + let b = window.visual_bounds().unwrap(); + assert!((b.x0 - 40.0).abs() < 0.01 && (b.x1 - 50.0).abs() < 0.01, "{b:?}"); + // The image's outline filled with its pixels' average. + assert_eq!(paint(layer[3].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.0, 0.5)))); + assert_eq!(layer[3].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0)))); + // The half-transparent square, opaque. + assert_eq!(layer[6].opacity, 1.0); +} + +#[test] +fn brushed_strokes_are_written_as_their_brush_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut line = Node::path( + doc.alloc_id(), + shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), + Appearance::basic(Paint::None, solid(Color::rgb(1.0, 0.0, 0.0)), 4.0), + ); + line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + doc.insert(Some(layer), usize::MAX, line).unwrap(); + let pieces = vectorcraft_brush::stroke_pieces( + &doc, + &vectorcraft_brush::find(&doc, "3 pt. Round").unwrap(), + &shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()).to_bezpath(), + doc.layers[0].children().unwrap()[0].appearance.stroke().unwrap(), + ); + let back = read_back(&doc); + let NodeKind::Group { children, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("a group of the brush's art") }; + assert_eq!(children.len(), pieces.len()); + let bounds = |n: &Node| n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 10.0).round())); + assert_eq!(children.iter().map(|c| bounds(c)).collect::>(), pieces.iter().map(bounds).collect::>()); +} + +#[test] +fn type_is_written_as_create_outlines_makes_it() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), underline: true, ..vectorcraft_doc::CharStyle::default() }; + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Under", style); + let mut t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + t.name = Some("Title".into()); + doc.insert(Some(layer), usize::MAX, t.clone()).unwrap(); + let outlined = vectorcraft_effects::outline_text(&t).unwrap(); + let back = read_back(&doc); + let group = &back.layers[0].children().unwrap()[0]; + assert_eq!(group.name.as_deref(), Some("Title")); + let rounded = |n: &Node| n.visual_bounds().map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 100.0).round())); + // The underline reaches below the glyphs: the bounds match only with it. + assert_eq!(rounded(group), rounded(&outlined)); +} + +#[test] +fn inline_graphics_in_type_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mark = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0), + ); + doc.symbols.push(Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(mark) }); + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Mark"), style)); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + // A stroke of the object's own above the Characters row: drawn over the inline graphic. + let red = solid(Color::rgb(1.0, 0.0, 0.0)); + text.appearance = + Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(red.clone(), 3.0))], contents_index: Some(0), ..Appearance::default() } + .into(); + doc.insert(Some(layer), usize::MAX, text).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let has = |p: &Node, c: &Paint| p.appearance.items.iter().any(|i| paint(i.paint()) == paint(c)); + let blue = solid(Color::rgb(0.0, 0.0, 1.0)); + let art: Vec<&Node> = all.iter().filter(|p| has(p, &blue)).collect(); + assert_eq!(art.len(), 1, "the inline graphic is written"); + // To the right of the letter, 18 pt high as the run's size makes it. + let b = art[0].visual_bounds().unwrap(); + assert!(b.x0 > 10.0 && (b.height() - 18.0).abs() < 0.01, "{b:?}"); + let at = |c: &Paint| all.iter().position(|p| has(p, c)).unwrap(); + assert!(at(&blue) < at(&red), "the stroke above the characters is over the inline graphic"); +} + +/// A scaled instance of a symbol with 9-slice guides is written as the canvas draws it: the corner +/// keeps its size while the rest stretches. +#[test] +fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { + let mut doc = Document::new(400.0, 400.0); + let layer = doc.layers[0].id; + let square = |doc: &mut Document, r: Rect| { + Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0)) + }; + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0))); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0))); + let art = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + doc.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice::default())); + let xf = Affine::scale_non_uniform(2.0, 1.5); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let sizes: Vec<(i64, i64)> = + all.iter().filter_map(|p| p.geometric_bounds()).map(|b| (b.width().round() as i64, b.height().round() as i64)).collect(); + assert!(sizes.contains(&(10, 10)), "the corner keeps its size: {sizes:?}"); + assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); +} + +/// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of +/// symbol art in all, with a warning. +#[test] +fn nested_symbol_instances_stop_at_the_symbol_art_limit() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. + let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); + let mut members = vec![empty; 4999]; + let square = shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)); + members.push(Arc::new(Node::path(doc.alloc_id(), square, Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0)))); + let leaves = Node::group(doc.alloc_id(), members); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaves) }); + let leaf = Arc::new(Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Leaf".into(), xf: Affine::IDENTITY })); + let trees = Node::group(doc.alloc_id(), vec![leaf; 5000]); + doc.symbols.push(Symbol { dynamic: false, name: "Tree".into(), art: Arc::new(trees) }); + let tree = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Tree".into(), xf: Affine::IDENTITY }); + doc.insert(Some(layer), usize::MAX, tree).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); + // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. + let text = String::from_utf8(out.program).unwrap(); + assert_eq!(text.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); +} + +#[test] +fn a_stained_symbol_instance_is_written_in_its_stained_colors() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let art = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0), + ); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); + let mut instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 10.0)) }); + instance.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + let stained = vectorcraft_brush::instance_art(&doc.symbols[0].art, &instance); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let NodeKind::Group { children, .. } = &back.layers[0].children().unwrap()[0].kind else { panic!("the instance's group") }; + assert_eq!(paint(children[0].appearance.items[0].paint()), paint(stained.appearance.items[0].paint())); + assert_ne!(paint(stained.appearance.items[0].paint()), paint(&solid(Color::rgb(0.0, 1.0, 0.0))), "the instance stains its art"); +} + +#[test] +fn a_layer_with_crop_marks_keeps_its_art_and_gets_the_marks() { + for clipping in [false, true] { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let at = Rect::new(50.0, 30.0, 90.0, 60.0); + if clipping { + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { path: shapes::rectangle(at), rule: FillRule::NonZero, live: None, clipping: true, guide: false }, + ); + doc.insert(Some(layer), usize::MAX, clip).unwrap(); + } + let square = Node::path(doc.alloc_id(), shapes::rectangle(at), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, square).unwrap(); + if let Some(l) = doc.layers.get_mut(0) { + let fx = vectorcraft_doc::Effect { id: vectorcraft_effects::CROP_MARKS.into(), params: serde_json::json!({}), visible: true }; + let l = Arc::make_mut(l); + let mut ap = (*l.appearance).clone(); + ap.effects.push(fx); + l.appearance = ap.into(); + if let NodeKind::Layer { clip, .. } = &mut l.kind { + *clip = clipping; + } + } + let name = doc.layers[0].name.clone(); + let back = read_back(&doc); + assert_eq!(back.layers.len(), 1); + assert_eq!(back.layers[0].name, name); + let members = back.layers[0].children().unwrap(); + assert_eq!(members.len(), 2, "clipping {clipping}: the square and the marks"); + assert!(matches!(members[0].kind, NodeKind::Path { .. })); + assert!(matches!(members[1].kind, NodeKind::Group { .. })); + // The marks lie around the square, outside a clipping path. + let marks = members[1].visual_bounds().unwrap(); + assert!(marks.x0 < at.x0 - 1.0 && marks.x1 > at.x1 + 1.0, "clipping {clipping}: {marks:?}"); + } +} + +#[test] +fn geometry_effects_in_a_symbol_are_written_as_their_result() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut ap = Appearance::basic(solid(Color::BLACK), Paint::None, 0.0); + ap.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 5}), visible: true }); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let b = back.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + // The 10 pt square offset by 5 pt, at (50, 20). + assert!((b.x0 - 45.0).abs() < 0.1 && (b.x1 - 65.0).abs() < 0.1 && (b.y0 - 15.0).abs() < 0.1 && (b.y1 - 35.0).abs() < 0.1, "{b:?}"); +} + +#[test] +fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut png = vec![]; + image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { key, width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default(), pixels: None }; + let mut im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + let shift = serde_json::json!({"amount": 100, "preserveLightness": false}); + let mut ap = (*im.appearance).clone(); + ap.effects.push(vectorcraft_doc::Effect { id: "adjust.shiftToColor".into(), params: shift, visible: true }); + im.appearance = ap.into(); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(im) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((50.0, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let fill = paint(all[0].appearance.items[0].paint()); + assert_ne!(fill, paint(&solid(Color::gray(0.5))), "the adjusted image is found"); + assert_ne!(fill, paint(&solid(Color::rgb(0.0, 0.0, 0.0))), "the image is adjusted"); +} + +#[test] +fn a_group_fill_in_a_pattern_tile_is_written() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let dot = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + let mut group = Node::group(doc.alloc_id(), vec![Arc::new(dot)]); + group.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Dots", vec![Arc::new(group)])); + let filled = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(20.0, 20.0, 40.0, 40.0)), + Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0), + ); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let red = paint(&solid(Color::rgb(1.0, 0.0, 0.0))); + assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == red)), "the tiles keep their group's fill"); +} + +/// A pattern fill that would place more tiles than a fill places is written as the shape filled +/// gray, as a pattern that isn't found is, instead of as nothing. +#[test] +fn a_pattern_fill_of_too_many_tiles_is_written_gray() { + let mut doc = Document::new(1200.0, 1200.0); + let layer = doc.layers[0].id; + let dot = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 0.5, 0.5)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0)); + let tiny = vectorcraft_doc::PatternDef { tile: Rect::new(0.0, 0.0, 1.0, 1.0), ..vectorcraft_doc::PatternDef::new("Tiny", vec![Arc::new(dot)]) }; + doc.patterns.push(tiny); + let filled = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(100.0, 100.0, 1100.0, 1100.0)), + Appearance::basic(Paint::Pattern { pattern: "Tiny".into(), xf: Affine::IDENTITY }, Paint::None, 0.0), + ); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let gray = paint(&solid(Color::gray(0.5))); + assert!(all.iter().any(|p| p.appearance.items.iter().any(|i| paint(i.paint()) == gray)), "{}", all.len()); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::PATTERN_TILES), "{:?}", out.warnings); +} + +/// An artboard's center mark, cross hairs and video safe areas are written as the bits of its +/// `DisplayMark` and read back. +#[test] +fn artboard_display_marks_are_written() { + let mut doc = Document::new(200.0, 100.0); + (doc.artboards[0].show_center_mark, doc.artboards[0].show_video_safe_areas) = (true, true); + assert!(program(&doc).contains("%_5 /Int (DisplayMark) ,\n")); + let back = read_back(&doc); + let ab = &back.artboards[0]; + assert!(ab.show_center_mark && !ab.show_cross_hairs && ab.show_video_safe_areas); +} + +#[test] +fn effects_on_a_fill_are_reported_left_out() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut fill = FillLayer::new(solid(Color::BLACK)); + fill.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({}), visible: true }); + let ap = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }; + let square = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), ap); + doc.insert(Some(layer), usize::MAX, square).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + +#[test] +fn images_too_large_to_average_are_mid_gray() { + let mut png = vec![]; + image::GrayImage::new(5000, 4000).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert_eq!(image_average_of(&png), paint(&solid(Color::gray(0.5)))); + let mut small = vec![]; + image::GrayImage::new(50, 40).write_to(&mut std::io::Cursor::new(&mut small), image::ImageFormat::Png).unwrap(); + assert_eq!(image_average_of(&small), paint(&solid(Color::rgb(0.0, 0.0, 0.0)))); +} + +/// The fill an image of `png` is written with, as the reader reads it. +fn image_average_of(png: &[u8]) -> String { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let blob = ImageBlob::new("image/png", png.to_vec()); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { key, width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + doc.insert(Some(layer), usize::MAX, im).unwrap(); + paint(read_back(&doc).layers[0].children().unwrap()[0].appearance.items[0].paint()) +} + +#[test] +fn a_document_without_an_artboard_is_an_error() { + let mut doc = Document::new(200.0, 100.0); + doc.artboards.clear(); + assert!(export_ai_editing(&doc, &opts()).is_err()); + doc.artboards.push(Artboard { rect: Rect::new(0.0, 0.0, 0.0, 10.0), ..Document::new(1.0, 1.0).artboards[0].clone() }); + assert!(export_ai_editing(&doc, &opts()).is_err()); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs new file mode 100644 index 0000000000..e0afd03660 --- /dev/null +++ b/crates/eps/src/ai_editing/writer.rs @@ -0,0 +1,819 @@ +//! The program's art and swatches (see [`super`] for the form). + +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; +use std::sync::Arc; + +use vectorcraft_color::cms::Cms; +use vectorcraft_color::swatch::{REGISTRATION, Swatch}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, LAYER_COLORS, LayerColor, Node, NodeKind, StrokeLayer}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape, SubPath}; +use vectorcraft_pathops::BoolOp; + +use crate::ai_ops::{self, average, unit}; +use crate::ps; + +/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left +/// out). Groups and layers need no limit of their own: a document is never deeper than +/// [`vectorcraft_doc::MAX_DEPTH`]. +const MAX_NEST: u32 = 8; +/// The side of the image whose pixels are averaged (larger ones are scaled down to it first). +const AVERAGE_SIDE: u32 = 64; +/// Most pixels an image may have to be decoded and averaged (more: mid gray). +const MAX_AVERAGED_PIXELS: u64 = 1 << 24; + +pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps"; +pub(super) const GRADIENTS: &str = "gradients are written as their average color in the editing data for other apps"; +const PATTERNS: &str = "pattern fills are written as their tiles cut to the shape in the editing data for other apps"; +pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps"; +pub(super) const CLIPPING: &str = + "clipping groups and clipping layers are written as their art cut to the clipping path in the editing data for other apps"; +pub(super) const IMAGES: &str = "images are written as their outlines filled with their average color in the editing data for other apps"; +pub(super) const LIVE: &str = + "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; +const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; +pub(super) const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is left out of the editing data for other apps"; +pub(super) const EFFECTS: &str = "live effects other than geometry effects are left out of the editing data for other apps"; +const SWATCHES: &str = "gradient and pattern swatches are left out of the editing data for other apps"; +pub(super) const SYMBOL_LIMIT: &str = "symbol instances past 16,000,000 objects of symbol art in all are left out of the editing data for other apps"; +const TEMPLATES: &str = "template layers are written as dimmed layers that don't print in the editing data for other apps"; + +pub(super) struct Writer<'a> { + doc: &'a Document, + /// The document → art space. + to_art: Affine, + cms: Arc, + pub(super) body: String, + pub(super) warnings: Vec, + /// The lock and hidden state (`A`, `Xw`) the last operators set; `None` at a layer's start. + state: Option<(bool, bool)>, + /// The region the art being written is cut to (a clipping path's, in document space). + clip: Option, + /// Symbols, live objects and pattern tiles being written inside one another. + nest: u32, + /// The XML ids objects are named with so far. + ids: HashSet, + /// The number the next object named after each XML id tries first. + next: HashMap, + /// The document's brushes, read the first time a brushed stroke is written. + brushes: Option>, + /// The objects of the symbol art written so far, within [`vectorcraft_doc::MAX_SYMBOL_NODES`]. + symbol_nodes: u64, +} + +impl<'a> Writer<'a> { + pub(super) fn new(doc: &'a Document, to_art: Affine) -> Self { + Self { + doc, + to_art, + cms: vectorcraft_color::cms::active(), + body: String::new(), + warnings: vec![], + state: None, + clip: None, + nest: 0, + ids: HashSet::new(), + next: HashMap::new(), + brushes: None, + symbol_nodes: 0, + } + } + + /// Whether the art of a symbol, `art`, may be written: the objects of the symbol art written + /// so far stay within [`vectorcraft_doc::MAX_SYMBOL_NODES`], as in the PDF part (instances of + /// symbols that hold instances multiply). Past it the art is left out, with a warning. + fn take_symbol(&mut self, art: &Node) -> bool { + let total = self.symbol_nodes.saturating_add(art.count() as u64); + if total > vectorcraft_doc::MAX_SYMBOL_NODES { + self.warn(SYMBOL_LIMIT); + return false; + } + self.symbol_nodes = total; + true + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn line(&mut self, s: &str) { + self.body.push_str(s); + self.body.push('\n'); + } + + // ---------- layers and objects ---------- + + /// A top-level layer. A layer that baking its effects made a group of the layer and the art an + /// effect adds (Crop Marks) is written as that layer holding the added art too, and any other + /// object at the top level in a layer of its own. + pub(super) fn layer(&mut self, n: &Node) { + if n.is_layer() { + return self.write_layer(n, &[]); + } + let members = n.children().map(Vec::as_slice).unwrap_or_default(); + self.transparency(n); + match members.iter().find(|c| c.is_layer()) { + Some(l) => { + // The group took the layer's name. The added art is outside the layer's clipping. + let layer = Node { name: l.name.clone().or_else(|| n.name.clone()), ..(**l).clone() }; + let added: Vec> = members.iter().filter(|c| !c.is_layer()).cloned().collect(); + self.write_layer(&layer, &added); + } + None => { + let mut layer = Node::layer(n.id, n.name.as_deref().unwrap_or("Layer"), LayerColor::Preset(0)); + (layer.visible, layer.locked) = (n.visible, n.locked); + self.write_layer(&layer, &[Arc::new(n.clone())]); + } + } + } + + /// A layer or sublayer: `%AI5_BeginLayer`, its options `Lb`, its name `Ln` and as an XML id, its art, then + /// `after` (outside its clipping path when it clips), `LB`, `%AI5_EndLayer--`. + fn write_layer(&mut self, n: &Node, after: &[Arc]) { + let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return }; + let [r, g, b] = match color { + LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1), + LayerColor::Custom(rgb) => *rgb, + }; + let index = match color { + LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i), + _ => -1, + }; + if *template { + self.warn(TEMPLATES); + } + self.transparency(n); + let flag = |b: bool| u8::from(b); + let dim = dim_images.unwrap_or(50); + let name = n.name.as_deref().unwrap_or("Layer"); + let _ = writeln!( + self.body, + "%AI5_BeginLayer\n{} {} {} {} {} 0 1 {index} {r} {g} {b} 0 {dim} 0 Lb\n{} Ln", + flag(n.visible), + flag(*preview), + flag(!n.locked), + flag(*printable && !*template), + flag(dim_images.is_some() || *template), + ps::bytes(name.as_bytes()), + ); + self.xml_id(if name.is_empty() { "Layer" } else { name }); + self.state = None; + if *clip { + self.warn(CLIPPING); + self.clip_contents(children); + } else { + for c in children { + self.node(c); + } + } + for c in after { + self.node(c); + } + self.line("LB\n%AI5_EndLayer--"); + // The reader's state carries over into the art that follows. + self.state = None; + } + + /// Set the lock and hidden state of the object that follows. + fn state(&mut self, locked: bool, hidden: bool) { + let (was_locked, was_hidden) = match self.state { + Some((l, h)) => (Some(l), Some(h)), + None => (None, None), + }; + if was_locked != Some(locked) { + self.line(if locked { "1 A" } else { "0 A" }); + } + if was_hidden != Some(hidden) { + self.line(if hidden { "1 Xw" } else { "0 Xw" }); + } + self.state = Some((locked, hidden)); + } + + /// Warn when `n` has transparency or effects the data leaves out. + fn transparency(&mut self, n: &Node) { + if n.opacity < 1.0 || n.blend != BlendMode::Normal || n.extras().mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(TRANSPARENCY); + } + let items = n.appearance.items.iter().filter(|i| i.visible()).flat_map(|i| i.effects()); + if n.appearance.effects.iter().chain(items).any(|e| e.visible) { + self.warn(EFFECTS); + } + } + + /// One object, its state before it and its name after it. + fn node(&mut self, n: &Node) { + if let NodeKind::Layer { .. } = n.kind { + return self.write_layer(n, &[]); + } + self.transparency(n); + self.state(n.locked, !n.visible); + let start = self.body.len(); + match &n.kind { + NodeKind::Layer { .. } => {} + NodeKind::Group { children, clip } => { + self.line("u"); + if *clip { + self.warn(CLIPPING); + self.clip_contents(children); + } else { + for c in children { + self.node(c); + } + } + self.line("U"); + } + NodeKind::Path { path, rule, guide, .. } => { + if *guide { + ai_ops::guide(&mut self.body, self.to_art, path, true); + } else { + self.shape(n, path, *rule); + } + } + NodeKind::Compound { children, rule } => { + let mut subpaths = vec![]; + for p in children.iter().filter_map(|c| c.path_data()) { + subpaths.extend(p.subpaths.iter().cloned()); + } + self.shape(n, &PathData::new(subpaths), *rule); + } + NodeKind::Text(_) => self.text(n), + NodeKind::Image(im) => { + let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1))); + let color = image_average(self.doc.image_blob(im).map(|b| b.bytes.as_slice())); + self.warn(IMAGES); + self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(color))); + } + NodeKind::SymbolInstance { symbol, xf } => { + self.warn(LIVE); + self.line("u"); + let doc = self.doc; + if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) + && self.nest < MAX_NEST + { + // Stained by the instance's fill, as the canvas draws it. + let stained = vectorcraft_brush::instance_art(&sym.art, n); + // Through the symbol's 9-slice guides when it has them, as the canvas draws it; + // placing through them needs room for the most objects it can make, and the + // objects it makes count. + let map = doc.symbol_slice_map(symbol, *xf); + let most = map.map_or(stained.count() as u64, |m| m.most_placed(&stained, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let art = match map { + Some(m) => m.place(&stained, doc), + None => { + let mut a = stained.clone(); + a.transform(*xf, false); + a + } + }; + let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; + (art, count) + }); + if let Some(mut art) = placed { + art.visible = true; + art.locked = false; + art.name = None; + self.nest += 1; + self.node(&art); + self.nest -= 1; + } else { + self.warn(SYMBOL_LIMIT); + } + } + self.line("U"); + } + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { + self.warn(LIVE); + self.line("u"); + if self.nest < MAX_NEST { + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.nest += 1; + for c in g.children().map(Vec::as_slice).unwrap_or_default() { + self.node(c); + } + self.nest -= 1; + } + self.line("U"); + } + } + if self.body.len() > start { + self.name(n); + } + } + + /// `n`'s name, after it (see [`Self::xml_id`]). + fn name(&mut self, n: &Node) { + let Some(name) = n.name.as_deref().filter(|s| !s.is_empty()) else { return }; + self.xml_id(name); + } + + /// An `/ArtDictionary` whose `AI10_ArtUID` is `name` as an XML id, numbered (`_` and 20 + /// digits, `_`) when a layer or another object has that id. + fn xml_id(&mut self, name: &str) { + let base = vectorcraft_doc::xml_id::from_object_name(name); + let mut id = base.clone(); + let next = self.next.entry(base.clone()).or_insert(0); + while self.ids.contains(&id) { + *next += 1; + id = format!("{base}_{next:020}_"); + } + self.ids.insert(id.clone()); + let _ = writeln!(self.body, "%_/ArtDictionary :\n%_/XMLUID : {} ; (AI10_ArtUID) ,\n%_;\n%_", ps::bytes(id.as_bytes())); + } + + /// A container's members, the first one's outline cutting the others: its fills painted + /// behind them (cut to the region outside too), its strokes over them, not cut by it. + fn clip_contents(&mut self, children: &[Arc]) { + let Some((first, rest)) = children.split_first() else { return }; + // Nothing to clip by hides the clipped art. + let Some((outline, rule)) = vectorcraft_effects::clip_outline(first) else { return }; + let paint = first.clip_paint(); + let outer = self.clip.clone(); + let region = self.cut(&PathData::from_bezpath(&outline), rule); + self.clip = Some(region); + if let Some(fill) = &paint.fill { + self.node(fill); + } + for c in rest { + self.node(c); + } + self.clip = outer; + if let Some(stroke) = &paint.stroke { + self.node(stroke); + } + } + + /// `path` (filled by `rule`) cut to the region the art is cut to, as a non-zero path. + fn cut(&self, path: &PathData, rule: FillRule) -> PathData { + match &self.clip { + Some(clip) => vectorcraft_pathops::boolean(path, rule, clip, FillRule::NonZero, BoolOp::Intersect), + None => vectorcraft_pathops::boolean(path, rule, path, rule, BoolOp::Intersect), + } + } + + // ---------- paths ---------- + + /// A path's fills and strokes: one object with one fill and a plain stroke over it where it + /// can be, else a group of an object for each (in paint order). + fn shape(&mut self, n: &Node, path: &PathData, rule: FillRule) { + if path.is_empty() { + return; + } + let items: Vec<&AppearanceItem> = n + .appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), + }) + .collect(); + if items.iter().any(|i| i.opacity() < 1.0 || i.blend() != BlendMode::Normal) { + self.warn(TRANSPARENCY); + } + let solid = |p: &Paint| matches!(p, Paint::Solid { .. }); + match items.as_slice() { + [] => self.path_object(path, rule, None, None), + [AppearanceItem::Fill(f)] if solid(&f.paint) => self.path_object(path, rule, Some(f), None), + [AppearanceItem::Stroke(s)] if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), + [AppearanceItem::Fill(f), AppearanceItem::Stroke(s)] if solid(&f.paint) && direct_stroke(s) => { + self.path_object(path, rule, Some(f), Some(s)) + } + [one] => self.item(path, rule, one), + items => { + self.line("u"); + for i in items { + self.item(path, rule, i); + } + self.line("U"); + } + } + } + + /// One fill or stroke of a path as an object of its own. + fn item(&mut self, path: &PathData, rule: FillRule, item: &AppearanceItem) { + match item { + AppearanceItem::Fill(f) => self.fill_object(path, rule, f), + AppearanceItem::Stroke(s) if direct_stroke(s) => self.path_object(path, rule, None, Some(s)), + AppearanceItem::Stroke(s) => { + self.warn(STROKES); + if self.brush_art(path, s) { + return; + } + let outline = stroke::outline_region(path, rule, s); + let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; + self.fill_object(&outline, FillRule::NonZero, &fill); + } + } + } + + /// A brushed stroke `st` of `path` as a group of its brush's art. False when it has no brush + /// the document knows (or brushes nest too deep). + fn brush_art(&mut self, path: &PathData, st: &StrokeLayer) -> bool { + if self.nest >= MAX_NEST { + return false; + } + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; + let art = vectorcraft_brush::stroke_pieces(doc, b, &path.to_bezpath(), st); + let state = self.state; + self.nest += 1; + self.line("u"); + for piece in &art { + self.node(piece); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + self.line("U"); + self.nest -= 1; + true + } + + /// A fill as an object of its own: a gradient as its average color, a pattern as its tiles + /// cut to the path. + fn fill_object(&mut self, path: &PathData, rule: FillRule, f: &FillLayer) { + match &f.paint { + Paint::Solid { .. } => self.path_object(path, rule, Some(f), None), + Paint::Gradient(g) => { + self.warn(GRADIENTS); + let flat = FillLayer { paint: Paint::solid(average(&g.gradient)), ..f.clone() }; + self.path_object(path, rule, Some(&flat), None); + } + Paint::Pattern { pattern, xf } => { + self.warn(PATTERNS); + let doc = self.doc; + let Some(def) = doc.pattern(pattern).filter(|_| self.nest < MAX_NEST) else { + let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + }; + let tiles = def.instances_in(*xf, path.to_bezpath().bounding_box()); + // More tiles than a fill places come back as none: the shape is filled gray, as a + // pattern that isn't found is. + if tiles.is_empty() && !def.art.is_empty() { + self.warn(PATTERN_TILES); + let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; + return self.path_object(path, rule, Some(&flat), None); + } + let outer = self.clip.clone(); + let region = self.cut(path, rule); + self.clip = Some(region); + self.nest += 1; + let state = self.state; + self.line("u"); + for t in &tiles { + self.node(t); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + self.line("U"); + self.nest -= 1; + self.clip = outer; + } + Paint::None => {} + } + } + + /// A path object painted with `fill` (a color) and `stroke` (a plain line of a color) over + /// it; several subpaths make a compound path. Where the art is cut to a clipping path, the + /// fill is the path's region cut to it and the stroke its outline cut to it, filled. + fn path_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { + if self.clip.is_none() { + return self.plain_object(path, rule, fill, stroke); + } + let both = fill.is_some() && stroke.is_some(); + if both { + self.line("u"); + } + if fill.is_some() || stroke.is_none() { + let region = self.cut(path, rule); + self.plain_object(®ion, FillRule::NonZero, fill, None); + } + if let Some(s) = stroke { + let outline = stroke::outline_region(path, rule, s); + let region = self.cut(&outline, FillRule::NonZero); + let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; + self.plain_object(®ion, FillRule::NonZero, Some(&fill), None); + } + if both { + self.line("U"); + } + } + + /// [`Self::path_object`] without cutting: the paint (`O`/`R` overprint, line style), `XR`, + /// each subpath's points and painting operator (`*u` … `*U` around several). + fn plain_object(&mut self, path: &PathData, rule: FillRule, fill: Option<&FillLayer>, stroke: Option<&StrokeLayer>) { + let subpaths: Vec<&SubPath> = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect(); + if subpaths.is_empty() { + return; + } + if let Some(f) = fill { + self.color(&f.paint, false); + self.line(if f.overprint { "1 O" } else { "0 O" }); + } + if let Some(s) = stroke { + self.color(&s.paint, true); + self.line(if s.overprint { "1 R" } else { "0 R" }); + ai_ops::line_style(&mut self.body, s); + } + self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" }); + let op = match (fill.is_some(), stroke.is_some()) { + (true, true) => 'b', + (true, false) => 'f', + (false, true) => 's', + (false, false) => 'n', + }; + let several = subpaths.len() > 1; + if several { + self.line("*u"); + } + for sp in subpaths { + ai_ops::subpath(&mut self.body, self.to_art, sp); + let op = if sp.closed { op } else { op.to_ascii_uppercase() }; + self.body.push(op); + self.body.push('\n'); + } + if several { + self.line("*U"); + } + } + + // ---------- type ---------- + + /// Type `n` as a group of what Type › Create Outlines makes of it + /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and + /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline + /// graphics as their symbols' art where the layout places them, as the PDF page draws them. + fn text(&mut self, n: &Node) { + self.warn(TYPE); + self.line("u"); + let doc = self.doc; + let resolved = match &n.kind { + NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), + _ => None, + }; + let node = match &resolved { + Some(t) => Node { kind: NodeKind::Text(Box::new(t.clone())), ..n.clone() }, + None => n.clone(), + }; + let state = self.state; + let outlined = vectorcraft_effects::outline_text(&node); + let pieces = outlined.as_ref().and_then(|o| o.children()).map(Vec::as_slice).unwrap_or_default(); + // The object's fills and strokes above the Characters row, when they paint, are the last + // piece: the inline graphics go under it, as the PDF page draws them. + let above = !node.appearance.split_contents().1.is_empty() && { + let bare = Node { appearance: Default::default(), ..node.clone() }; + let glyphs = vectorcraft_effects::outline_text(&bare).and_then(|o| o.children().map(Vec::len)).unwrap_or_default(); + pieces.len() > glyphs + }; + let (under, over) = pieces.split_at(pieces.len() - usize::from(above)); + for c in under { + self.node(c); + } + if let Some(t) = resolved.as_ref().filter(|_| self.nest < MAX_NEST) { + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + for ig in &layout.inlines { + let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue }; + let Some(sym) = doc.symbols.iter().find(|x| x.name == art.symbol) else { continue }; + if !self.take_symbol(&sym.art) { + continue; + } + self.warn(LIVE); + let mut art_node = (*sym.art).clone(); + // Strokes scale with the art, as on the canvas. + art_node.transform(t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol), true); + (art_node.visible, art_node.locked, art_node.name) = (true, false, None); + self.nest += 1; + self.node(&art_node); + self.nest -= 1; + } + } + for c in over { + self.node(c); + } + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + self.line("U"); + } + + // ---------- color ---------- + + /// Set the fill (or with `stroke`, the stroke) color to solid paint `p`: a spot or global + /// swatch by its name, Registration, else the color itself. + fn color(&mut self, p: &Paint, stroke: bool) { + let Paint::Solid { color, swatch, tint } = p else { + return self.plain_color(Color::gray(1.0), stroke); + }; + if swatch.as_deref() == Some(REGISTRATION) { + let mut s = String::from("1 1 1 1 "); + s.push_str(&ps::bytes(REGISTRATION.as_bytes())); + s.push(' '); + ps::push_num(&mut s, f64::from(1.0 - unit(*tint))); + s.push_str(if stroke { " XS" } else { " Xs" }); + return self.line(&s); + } + let doc = self.doc; + if let Some(name) = swatch + && let Some(sw) = doc.swatch(name).filter(|s| s.global || s.spot) + && let Paint::Solid { color: full, swatch: None, .. } = sw.paint + { + let mut s = String::new(); + self.named(&mut s, name, full, *tint, sw.spot, stroke); + return self.body.push_str(&s); + } + self.plain_color(*color, stroke); + } + + /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and + /// Lab as RGB), with its CMYK equivalent. + fn plain_color(&mut self, color: Color, stroke: bool) { + let s = &mut self.body; + let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; + match color { + // Gray is ink coverage; the format's gray is lightness. + Color::Gray { k } => { + ps::push_num(s, f64::from(1.0 - unit(k))); + let _ = writeln!(s, " {}", op("g", "G")); + } + Color::Cmyk { c, m, y, k } => { + ps::push_nums(s, &[c, m, y, k].map(|v| f64::from(unit(v)))); + let _ = writeln!(s, "{}", op("k", "K")); + } + Color::Rgb { .. } | Color::Lab { .. } => { + let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit); + ps::push_nums(s, &cmyk.map(f64::from)); + ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + let _ = writeln!(s, "{}", op("Xa", "XA")); + } + } + } + + /// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to + /// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full + /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before + /// `Xx` (spot) or `Xk` (global process). + fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) { + let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit); + ps::push_nums(s, &cmyk.map(f64::from)); + let model = match full { + Color::Rgb { r, g, b } => { + ps::push_nums(s, &[r, g, b].map(|v| f64::from(unit(v)))); + Some(1) + } + Color::Lab { l, a, b } => { + ps::push_nums(s, &[f64::from(l), f64::from(a), f64::from(b)]); + Some(2) + } + _ if spot => None, + _ => Some(0), + }; + s.push_str(&ps::bytes(name.as_bytes())); + s.push(' '); + ps::push_num(s, f64::from(1.0 - unit(tint))); + let op = match (model, spot) { + (None, _) => "x", + (Some(_), true) => "Xx", + (Some(_), false) => "Xk", + }; + if let Some(m) = model { + let _ = write!(s, " {m}"); + } + let op = if stroke { op.to_ascii_uppercase() } else { op.to_string() }; + let _ = writeln!(s, " {op}"); + } + + // ---------- swatches ---------- + + /// The swatches: `%AI5_BeginPalette`, `0 0 Pb`, the Registration swatch, each color swatch's color and `(name) Pc` + /// (the ungrouped ones first, then each color group's after `0 (name) count Pg`), `PB`, + /// `%AI5_EndPalette`. + pub(super) fn palette(&mut self, s: &mut String) { + s.push_str("%AI5_BeginPalette\n0 0 Pb\n"); + self.registration_cell(s); + let doc = self.doc; + for sw in doc.swatches.iter() { + self.swatch(s, sw); + } + for g in doc.swatch_groups.iter() { + let colors: Vec<&Swatch> = g.swatches.iter().filter(|sw| writable(sw)).collect(); + let _ = writeln!(s, "0 {} {} Pg", ps::bytes(g.name.as_bytes()), colors.len()); + for sw in &g.swatches { + self.swatch(s, sw); + } + } + s.push_str("PB\n%AI5_EndPalette\n"); + } + + /// The Registration swatch, which every palette in the files users sent starts with: in a + /// CMYK document `1 1 1 1 ([Registration]) 0 Xs`, in an RGB one black's CMYK equivalent, + /// `0 0 0 ([Registration]) 0 1 Xz`; then `([Registration]) Pc`. + fn registration_cell(&self, s: &mut String) { + let name = ps::bytes(REGISTRATION.as_bytes()); + if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { + let _ = writeln!(s, "1 1 1 1 {name} 0 Xs"); + } else { + let black = Color::rgb(0.0, 0.0, 0.0); + let cmyk = self.cms.to_cmyk(&black, self.cms.settings().intent).map(unit); + ps::push_nums(s, &cmyk.map(f64::from)); + let _ = writeln!(s, "0 0 0 {name} 0 1 Xz"); + } + let _ = writeln!(s, "{name} Pc"); + } + + /// One swatch of the palette: a color swatch's color and `(name) Pc`; gradient and pattern + /// swatches are left out. + fn swatch(&mut self, s: &mut String, sw: &Swatch) { + if sw.name == REGISTRATION || matches!(sw.paint, Paint::None) { + return; + } + if !writable(sw) { + return self.warn(SWATCHES); + } + let Paint::Solid { color, swatch, .. } = &sw.paint else { return }; + if swatch.is_none() && (sw.global || sw.spot) { + self.named(s, &sw.name, *color, 1.0, sw.spot, false); + } else { + let body = std::mem::take(&mut self.body); + self.plain_color(*color, false); + s.push_str(&std::mem::replace(&mut self.body, body)); + } + let _ = writeln!(s, "{} Pc", ps::bytes(sw.name.as_bytes())); + } + + /// The spot color swatches, with their full colors' CMYK equivalents, for the header. + pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> { + let intent = self.cms.settings().intent; + self.doc + .swatches_iter() + .filter(|sw| sw.spot) + .filter_map(|sw| match sw.paint { + Paint::Solid { color, swatch: None, .. } => { + Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit))) + } + _ => None, + }) + .collect() + } +} + +/// Is `sw` a color swatch the palette writes? +fn writable(sw: &Swatch) -> bool { + matches!(sw.paint, Paint::Solid { .. }) && sw.name != REGISTRATION +} + +/// Does a plain line of one color draw `s` (the line style operators can write it)? +fn direct_stroke(s: &StrokeLayer) -> bool { + matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s) +} + +/// The image `bytes` decoded, when its header gives at most [`MAX_AVERAGED_PIXELS`] pixels; the +/// decoder may allocate at most 8 bytes a pixel. +fn decode_bounded(bytes: &[u8]) -> Option { + let reader = || -> image::ImageResult>> { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_AVERAGED_PIXELS * 8); + r.limits(limits); + Ok(r) + }; + let (w, h) = reader().and_then(|r| r.into_dimensions()).ok()?; + if u64::from(w) * u64::from(h) > MAX_AVERAGED_PIXELS { + return None; + } + reader().and_then(|r| r.decode()).ok() +} + +/// The average color of the image `bytes` (scaled down first), transparent pixels over white; +/// mid gray when it can't be decoded or has more than [`MAX_AVERAGED_PIXELS`] pixels. +fn image_average(bytes: Option<&[u8]>) -> Color { + let Some(img) = bytes.and_then(decode_bounded) else { return Color::gray(0.5) }; + let img = if img.width() > AVERAGE_SIDE || img.height() > AVERAGE_SIDE { img.thumbnail(AVERAGE_SIDE, AVERAGE_SIDE) } else { img }; + let rgba = img.to_rgba8(); + let mut sum = [0u64; 3]; + let mut n = 0u64; + for px in rgba.pixels() { + let [r, g, b, a] = px.0; + let over = |c: u8| (u64::from(c) * u64::from(a) + 255 * (255 - u64::from(a))) / 255; + sum[0] += over(r); + sum[1] += over(g); + sum[2] += over(b); + n += 1; + } + if n == 0 { + return Color::gray(0.5); + } + let c = |v: u64| (v as f32 / n as f32 / 255.0).clamp(0.0, 1.0); + Color::rgb(c(sum[0]), c(sum[1]), c(sum[2])) +} diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs new file mode 100644 index 0000000000..cfa3928eca --- /dev/null +++ b/crates/eps/src/ai_ops.rs @@ -0,0 +1,97 @@ +//! Operators the legacy `.ai` writer ([`crate::legacy_ai`]) and the editing data writer +//! ([`crate::ai_editing`]) both write: path points, guides and line style (Adobe Illustrator File +//! Format Specification, version 7.0, §5.3, §5.4 and §6), and the colors they write in place of +//! the paint they can't hold. + +use std::fmt::Write; + +use vectorcraft_color::{Color, Gradient}; +use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer}; +use vectorcraft_geom::{Affine, Anchor, AnchorKind, PathData, Point, SubPath}; + +use crate::ps; + +/// One subpath's points, taken into art space by `xf`: `m`, then a line or curve to each point +/// (lower case: the point is smooth, upper case: a corner, §5.3.2), and for a closed one the +/// segment back to the start, which says whether the start is smooth or a corner (as the +/// specification's examples close paths: the start point again, then the painting operator). +pub(crate) fn subpath(s: &mut String, xf: Affine, sp: &SubPath) { + let Some(first) = sp.anchors.first() else { return }; + let pt = |s: &mut String, p: Point| { + let p = xf * p; + ps::push_nums(s, &[p.x, p.y]); + }; + pt(s, first.p); + s.push_str("m\n"); + let segment = |s: &mut String, a: &Anchor, b: &Anchor| { + let straight = a.h_out == a.p && b.h_in == b.p; + let smooth = b.kind == AnchorKind::Smooth; + if straight { + pt(s, b.p); + s.push_str(if smooth { "l\n" } else { "L\n" }); + } else { + pt(s, a.h_out); + pt(s, b.h_in); + pt(s, b.p); + s.push_str(if smooth { "c\n" } else { "C\n" }); + } + }; + for pair in sp.anchors.windows(2) { + if let [a, b] = pair { + segment(s, a, b); + } + } + if sp.closed + && sp.anchors.len() > 1 + && let Some(last) = sp.anchors.last() + { + segment(s, last, first); + } +} + +/// A guide (§6): for each subpath, `(N) *`, its points, `(N) *`; with `closing`, a closed +/// subpath ends `(n) *` (the painting operator that closes it first). +pub(crate) fn guide(s: &mut String, xf: Affine, path: &PathData, closing: bool) { + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + s.push_str("(N) *\n"); + subpath(s, xf, sp); + s.push_str(if closing && sp.closed { "(n) *\n" } else { "(N) *\n" }); + } +} + +pub(crate) fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +/// The average colour of a gradient's stops (in RGB). +pub(crate) fn average(g: &Gradient) -> Color { + let n = g.stops.len().max(1) as f32; + let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]); + Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) +} + +/// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. +pub(crate) fn line_style(s: &mut String, st: &StrokeLayer) { + let cap = match st.cap { + LineCap::Butt => 0, + LineCap::Round => 1, + LineCap::Square => 2, + }; + let join = match st.join { + LineJoin::Miter => 0, + LineJoin::Round => 1, + LineJoin::Bevel => 2, + }; + ps::push_num(s, st.width); + let _ = write!(s, " w {cap} J {join} j "); + ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 }); + s.push_str(" M ["); + let dash = st.dash.as_ref().filter(|d| d.is_dashed()); + if let Some(d) = dash { + ps::push_nums(s, &d.pattern); + s.pop(); + } + s.push_str("] "); + ps::push_num(s, dash.map_or(0.0, |d| d.offset)); + s.push_str(" d\n"); +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 9c84001f7e..f122812715 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,6 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; +pub(crate) use native::scale_document; pub use native::{ai_alone, ai_alone_on_page, is_loss, layered_ai}; pub use text::family_style; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 98f4085786..ea437a4d13 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -38,6 +38,8 @@ pub(crate) use frames::text_frames; pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, read_beside, variables}; pub(crate) use read::{join_and, slot_frame, slot_of}; +pub(crate) use read::is_look_slot; + #[cfg(test)] mod tests; diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 93cc0af6b3..53555528c8 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -159,6 +159,8 @@ pub struct Structure { pub envelopes_drawn: Vec<(NodeId, Node)>, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, + /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. + pub canvas_scale: f64, pub warnings: Vec, } @@ -325,6 +327,8 @@ struct Reader<'a> { /// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None` /// when the declared factor is not applied ([`read`]). large_canvas: Option, + /// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not. + canvas_scale: f64, /// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels). ruler_units: Option, /// This reader reads a pattern's definition (see [`Reader::define_pattern`]). @@ -350,6 +354,11 @@ fn marker(c: &str) -> &str { word.strip_prefix("AI").map(|r| r.trim_start_matches(|ch: char| ch.is_ascii_digit())).and_then(|r| r.strip_prefix('_')).unwrap_or(word) } +/// Is `name` that of a text slot for type that a look draws ([`document::LOOK_SLOT`])? +pub(crate) fn is_look_slot(name: Option<&str>) -> bool { + name == Some(document::LOOK_SLOT) +} + impl<'a> Reader<'a> { fn new(data: &'a [u8]) -> Self { let mut doc = Document::new(1.0, 1.0); @@ -435,6 +444,7 @@ impl<'a> Reader<'a> { art_size: None, template_center: None, large_canvas: None, + canvas_scale: 1.0, ruler_units: None, pattern: None, pattern_tiles: HashMap::new(), diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 806cd270f8..1df1b2d9c1 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -15,17 +15,21 @@ impl<'a> Reader<'a> { if self.pattern.is_some() { self.unreadable("type in a pattern", false); } - // `1 /FreeUndo`: the type in the drawn look of a text object that has an appearance. Its - // story is one of another text document (`/AI11UndoFreeTextDocument`), which counts its - // stories on its own; the object itself follows the look, on `%_` lines, and is the type. - if o.nums("FreeUndo").first() == Some(&1.0) { - return Ok(()); - } + // `1 /FreeUndo`: type in a drawn look. Its story is one of another text document + // (`/AI11UndoFreeTextDocument`), which counts its stories on its own, so its slot has none. + // In the look of a text object that has an appearance, the object itself follows the look, + // on `%_` lines, and is the type (`style_marker` takes the slot out); in the look of a layer + // or a group it is the type of a copy that the look draws. + let look = o.nums("FreeUndo").first() == Some(&1.0); let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32); - let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0)); + let (story, frame) = (index("StoryIndex").filter(|_| !look), index("FrameIndex").unwrap_or(0)); let gs = self.gs.clone(); let mut n = self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs); - n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}:{frame}"))); + n.name = Some(match story { + _ if look => super::document::LOOK_SLOT.to_string(), + Some(s) => format!("{TEXT_SLOT}{s}:{frame}"), + None => TEXT_SLOT.to_string(), + }); // The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own. n.opacity = gs.opacity; n.blend = gs.blend; diff --git a/crates/eps/src/import/ai/read/containers.rs b/crates/eps/src/import/ai/read/containers.rs index 173e22ed00..a37471ba61 100644 --- a/crates/eps/src/import/ai/read/containers.rs +++ b/crates/eps/src/import/ai/read/containers.rs @@ -662,8 +662,12 @@ fn has_text_slot(node: &Node) -> bool { !slots.is_empty() } -/// The source text inside an envelope options dictionary, carried through its containing metadata. +/// The source text inside an envelope options dictionary, carried through its containing metadata +/// (not type that a look draws, which isn't the envelope's source text). fn envelope_text_slots(node: &Node, out: &mut Vec) { + if super::is_look_slot(node.name.as_deref()) { + return; + } if slot_of(node.name.as_deref()).is_some() { out.push(node.clone()); } else if let Some(children) = node.children() { diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index ed838b51b6..cf047363a8 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -208,21 +208,29 @@ impl<'a> Reader<'a> { self.template_center = two(v); self.template = four(v).or(self.template); } else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") { - // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N - // being this; the printed page applies the same factor, so the art reaches the page's - // size once it is applied to the art space ([`Self::ensure_space`]). A value outside - // the range Illustrator writes is left alone. - if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::()) - && (1.0..=100.0).contains(&n) - { - *scale = n; - } + self.large_canvas_scale(v); } else if let Some(v) = line.strip_prefix("AI9_ColorModel:") { self.color_model = Some(v.trim() == "2"); } Ok(()) } + /// `%AI24_LargeCanvasScale:` with its value `v`. + fn large_canvas_scale(&mut self, v: &str) { + // Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N + // being this; the printed page applies the same factor, so the art reaches the page's + // size once it is applied to the art space ([`Self::ensure_space`]). A value outside + // the range Illustrator writes is left alone. + if let Ok(n) = v.trim().parse::() + && (1.0..=100.0).contains(&n) + { + self.canvas_scale = n; + if let Some(scale) = &mut self.large_canvas { + *scale = n; + } + } + } + /// `%AI3_Note:` (or `%%Note:`) sets the note of the paths that follow, an empty one clearing it, /// and `%AI5_NoteMore:` adds a line to it. The text is kept as written, up to [`MAX_NOTE`] /// characters. diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index f38ec76239..63dc189687 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -121,6 +121,10 @@ pub(crate) fn slot_frame(name: Option<&str>) -> usize { name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0) } +/// The name of the text slot for type that a look draws (`1 /FreeUndo`): [`TEXT_SLOT`] with no +/// story, as [`slot_of`] reads it. +pub(crate) const LOOK_SLOT: &str = "\u{0}text:look"; + /// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't /// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to /// call them (#1068). @@ -723,6 +727,7 @@ impl<'a> Reader<'a> { placed_elsewhere: self.placed_elsewhere, envelopes_drawn: self.envelopes_drawn, styles_read, + canvas_scale: self.canvas_scale, warnings, }) } diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index e8ff52f6c7..9c5878fe05 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -596,6 +596,18 @@ impl<'a> Reader<'a> { { self.looks.insert(look.id); } + // The type that a text object's look draws is the object's own, whatever comes of its style. + if after + && let Some(f) = self.frames.last_mut() + && let [.., look, object] = f.children.as_mut_slice() + && slot_of(object.name.as_deref()).is_some() + && !super::is_look_slot(object.name.as_deref()) + && self.hidden_ids.contains(&object.id) + && self.hidden_ids.contains(&look.id) == hidden + && matches!(look.kind, NodeKind::Group { clip: false, .. }) + { + strip_look_type(look); + } if code != 1.0 || style.is_empty() || !after { return; } @@ -890,6 +902,16 @@ fn with_look(layer: &mut AppearanceItem, look: &crate::import::ai::style::ItemLo } } +/// Remove from `n` the text slots of the type its look draws ([`super::is_look_slot`]), in its groups too. +fn strip_look_type(n: &mut Node) { + if let Some(children) = n.children_mut() { + children.retain(|c| !super::is_look_slot(c.name.as_deref())); + for c in children.iter_mut().filter(|c| c.children().is_some_and(|k| !k.is_empty())) { + strip_look_type(Arc::make_mut(c)); + } + } +} + /// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). fn strip(n: &mut Node, hidden: &HashSet) { if let Some(children) = n.children_mut() { diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 52248aa1b7..c1061901f8 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2250,6 +2250,33 @@ fn text_objects_are_slots_named_after_their_story() { let r = read_ok(&layer(&format!("{look}{own}1 (Style) XW\n"))); let a = art(&r.doc); assert_eq!((a.len(), a[0].children().map(Vec::len), slot_of(a[1].name.as_deref())), (2, Some(1), Some(Some(7)))); + // The type of the look in a group of it is the object's too. + let nested = look.replacen("u\n", "u\nu\n", 1).replacen("U\n", "U\nU\n", 1); + let r = read_ok(&layer(&format!("{nested}{own}1 (Style) XW\n"))); + let mut slots = vec![]; + r.doc.walk(|n| { + if let Some(story) = slot_of(n.name.as_deref()) { + slots.push(story); + } + }); + assert_eq!(slots, [Some(7)]); + // Whatever comes of the object's style, the type of its look is the object's. + for marker in ["1 () XW", "2 (Style) XW"] { + let r = read_ok(&layer(&format!("{look}{own}{marker}\n"))); + let mut looks = 0; + r.doc.walk(|n| looks += usize::from(super::is_look_slot(n.name.as_deref()))); + assert_eq!(looks, 0, "{marker}"); + } + // In the look of a group, the type is that of a copy the look draws: a slot without a story. + let group = "u\n%_/AI11Text :\n%_0 /FreeUndo ,\n%_0 /FrameIndex ,\n%_7 /StoryIndex ,\n%_;\n%_U\n"; + let r = read_ok(&layer(&format!("{look}{group}1 (Style) XW\n"))); + let mut slots = vec![]; + r.doc.walk(|n| { + if slot_of(n.name.as_deref()).is_some() { + slots.push(n.name.clone()); + } + }); + assert!(slots.len() == 1 && slots.iter().all(|n| super::is_look_slot(n.as_deref())), "{slots:?}"); } /// A file in the legacy format (version 7) whose layer holds `art`, crop marks 0 0 200 100. diff --git a/crates/eps/src/import/data/ops.rs b/crates/eps/src/import/data/ops.rs index c5c588557c..baca4d0ed8 100644 --- a/crates/eps/src/import/data/ops.rs +++ b/crates/eps/src/import/data/ops.rs @@ -7,6 +7,8 @@ use super::{ /// Most bytes one filter or image reads. pub(super) const MAX_DATA: usize = 1 << 28; +/// The longest chain of filters over one pending file (real programs stack two or three). +const MAX_FILTER_CHAIN: usize = 64; /// Most pixels an image may have. const MAX_PIXELS: usize = 1 << 26; @@ -294,7 +296,16 @@ impl Interp<'_> { let pending = match &*f.borrow() { Stream::Current => Some((vec![], None)), Stream::Buf { .. } if running => Some((vec![], Some(f.clone()))), - Stream::Pending { chain, from } => Some((chain.clone(), from.clone())), + Stream::Pending { chain, from } => { + // Cloning the chain copies each filter's delimiter: bound and charge it. + if chain.len() >= MAX_FILTER_CHAIN { + return Err(PsError::Limit("too many filters over one file")); + } + let copied = chain.iter().map(|c| c.sub.1.len() + 1).sum::(); + self.alloc(copied)?; + self.spend((copied / 256) as u64)?; + Some((chain.clone(), from.clone())) + } Stream::Buf { .. } => None, }; match pending { diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 128ce4cb81..ce7eac542c 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -45,6 +45,7 @@ mod layers; mod plan; mod slots; +pub(crate) use build::scale_document; #[cfg(test)] use build::{Frame, MADE, prune_unseen}; #[cfg(test)] @@ -199,4 +200,66 @@ mod tests { let shifted = Rect::new(0.0, 0.0, 500.0, 500.0); assert!(view.difference(&vector, shifted, false).any > MAX_DIFFERENCE, "wrong framing remains an error"); } + + /// The boxes `solid_regions` gives for a page of images are in the page's points: the + /// comparison is drawn at a multiple of the scale and resized, and the two sizes round + /// differently on the short side, so a box at the far edge does not drift out of the object + /// that makes it. + #[test] + fn solid_regions_of_a_raster_page_lie_in_the_object_that_differs() { + for bottom in (884..=900).map(f64::from) { + let object = Rect::new(1000.0, 0.0, 3000.0, bottom); + for r in raster_page_regions(object, 4000.0, 1006.0) { + assert!( + r.x0 >= object.x0 - 1.0 && r.y0 >= object.y0 - 1.0 && r.x1 <= object.x1 + 1.0 && r.y1 <= object.y1 + 1.0, + "{r:?} lies outside {object:?}" + ); + } + } + } + + /// The boxes `solid_regions` gives for a `width` × `height` page that is one image of a black + /// `object`, compared with layers that hold nothing. + fn raster_page_regions(object: Rect, width: f64, height: f64) -> Vec { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Appearance, ImageBlob, ImageObject}; + let mut vector = Document::new(width, height); + let layer = vector.layers[0].id; + let n = Node::path( + vector.alloc_id(), + vectorcraft_geom::shapes::rectangle(object), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + vector.insert(Some(layer), usize::MAX, n).unwrap(); + let rect = vector.artboards[0].rect; + let source = vectorcraft_render::Renderer::new().render_region(&vector, rect, 1.0, true); + let mut png = Vec::new(); + image::RgbaImage::from_raw(source.width, source.height, source.to_straight()) + .unwrap() + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + let mut page = Document::new(width, height); + page.images.insert(key.clone(), blob); + let image = Node::new( + page.alloc_id(), + NodeKind::Image(Box::new(ImageObject { + key, + width: source.width, + height: source.height, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })), + ); + page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap(); + assert_eq!(comparison_samples(&page), 4); + let frame = Frame { page: &page, page_rect: rect, rect, whole: false, pages: vec![(rect, Affine::IDENTITY)] }; + let mut view = PageView::new(&frame); + let (_, regions) = view.solid_regions(&Document::new(width, height), rect, false).unwrap(); + assert!(!regions.is_empty()); + regions + } } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index f73121f235..e94dccfb5f 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1,8 +1,8 @@ -use super::compare::{MAX_DIFFERENCE, PageView, compare, difference}; +use super::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, compare, difference}; use super::plan::{made_only, make_slots, plan, readable_all}; use super::slots::{ - ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, SlotInfo, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, - fill_slots, letters, text_objects, words, + ART_LEFT_OUT, Content, DREW_DIFFERENTLY, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, SlotInfo, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, + extent, few, fill_slots, letters, text_objects, words, }; use super::{Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, Vec2, ai}; @@ -56,7 +56,8 @@ pub(super) fn build( /// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page` /// (width and height, either way round) points large in units of `unit` points: the document is /// drawn `unit` times as large when its first artboard, or its art's box, has the page's size in -/// those units and not in points, as [`build_from`] decides with a page. +/// those units and not in points, as [`build_from`] decides with a page; else as many times as +/// large as its declared large canvas scale when it has the page's size at that scale. pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec), String> { let structure = ai::read(data, false)?; let board = structure.artboard.map(|a| structure.to_doc.transform_rect_bbox(a)); @@ -65,12 +66,12 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result< let fits = |r: Option, k: f64| { r.is_some_and(|r| [(w, h), (h, w)].iter().any(|(a, b)| (r.width() - a / k).abs() <= 0.5 && (r.height() - b / k).abs() <= 0.5)) }; - let in_units = |r: Option| unit.is_finite() && unit > 1.0 && fits(r, unit) && !fits(r, 1.0); - let scale = in_units(board) || (!fits(board, 1.0) && in_units(art)); + let in_units = |r: Option, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0); + let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k))); let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?; - if scale { + if let Some(k) = scale { // The graphic styles here are all the file's, drawn in its units too. - scale_document(&mut doc, unit, true); + scale_document(&mut doc, k, true); } Ok((doc, notes)) } @@ -99,16 +100,19 @@ pub(super) fn build_from( placed_elsewhere, envelopes_drawn, styles_read, + canvas_scale, warnings, } = structure; - // Editing data in the units of the page's user space ([`in_units`]) is compared with the page - // scaled down to those units, and the document read from it is scaled up by `unit`. + // Editing data in the units of the page's user space ([`in_units`]), or at its declared large + // canvas scale ([`page_scale`]), is compared with the page scaled down to those units, and the + // document read from it is scaled up by `unit`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); // A page cropped to the art (see below) is in those units as well, when the art's box is and // the artboard isn't the page in points. let art = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d))); - let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit)); - let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| { + let scaled = page_scale(board, art, visible.filter(|_| matches!(page, Page::Artboard)), unit, canvas_scale); + let unit = scaled.unwrap_or(unit); + let small = visible.filter(|_| scaled.is_some()).map(|v| { let mut v = v.clone(); scale_document(&mut v, 1.0 / unit, false); v @@ -224,6 +228,7 @@ pub(super) fn build_from( } let mut infos = vec![]; collect_slots(&doc.layers, true, &mut infos); + super::slots::limit_look_slots(&mut doc.layers, &mut infos, shown.len(), outlined); // Each text object's own frame or path, by its story and frame. let frames = ai::text_frames(data); for info in &mut infos { @@ -341,10 +346,22 @@ pub(super) fn build_from( doc = other; notes.extend(unseen_note(unseen).into_iter().chain(note)); } - _ => { - let more = drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow)?; - notes.extend(more); - } + other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, ¬es, unseen, trust, why, reflow) { + Ok(more) => notes.extend(more), + // The objects that draw differently from the page come in as its art, from + // the placement that draws closer to it. + Err(why) => { + let other = other.map(|(_, other, unseen)| (other, unseen)); + let placements = closer_first(&frame, &mut view, (doc, unseen), other, reflow); + let Some((kept, unseen, kept_notes)) = placements.into_iter().find_map(|(base, unseen)| { + keep_differing(&base, &frame, &mut view, &threaded_frames, &shown, reflow).map(|(kept, n)| (kept, unseen, n)) + }) else { + return Err(why); + }; + doc = kept; + notes.extend(unseen_note(unseen).into_iter().chain(kept_notes)); + } + }, }, } } @@ -766,6 +783,135 @@ fn replace_unread( Ok(counts.into_iter().map(|(kind, count)| format!("{kind} ({count})")).collect()) } +/// Most objects of the layers that come in as the page's art where they draw differently from it. +const MAX_DIFFERING: usize = 64; + +/// Most pairs of a patch where the layers and the page differ and a top-level object that may hold +/// it, weighed to find the objects that come in as the page's art. +const MAX_PATCH_PAIRS: usize = 4_000_000; + +/// The layers as read and at the placed files' other placement (each with its count of text +/// objects the page doesn't draw), the one that draws closer to the page first. `reflow` as in +/// [`PageView::difference`]. +fn closer_first( + frame: &Frame<'_>, + view: &mut PageView, + doc: (Document, usize), + other: Option<(Document, usize)>, + reflow: bool, +) -> Vec<(Document, usize)> { + match other { + Some(other) if view.difference(&other.0, frame.rect, reflow).any < view.difference(&doc.0, frame.rect, reflow).any => vec![other, doc], + Some(other) => vec![doc, other], + None => vec![doc], + } +} + +/// `doc`, whose layers draw differently from the page (an object in another color, or one that only +/// one of them draws), with each of its top-level objects that holds such a difference replaced by the +/// page's art in its bounds, when that makes the two look alike → the document and its notes: the +/// comparison's, and how many objects were replaced on each layer (by name, in the layers' order). +/// `None` when only edges or type differ, a difference lies outside every object, more than half +/// of the objects (or more than [`MAX_DIFFERING`]) hold one, one of them holds type or a wrap +/// object, the page's art can't replace one of them ([`replace_unread`]), the page's type of a text +/// object that stays is copied into a replacement, or the two still differ. `shown` is the page's +/// type of each text object that shows. `reflow` as in [`PageView::difference`]. +fn keep_differing( + doc: &Document, + frame: &Frame<'_>, + view: &mut PageView, + threaded_frames: &BTreeSet, + shown: &[Vec>], + reflow: bool, +) -> Option<(Document, Vec)> { + /// The top-level objects that show and print (the outermost objects under a layer or + /// sublayer), with the name of that layer or sublayer as the Layers panel shows it and their + /// bounds. + fn top_level(nodes: &[Arc], layer: &str, out: &mut Vec<(String, NodeId, Rect)>) { + for n in nodes.iter().filter(|n| n.visible) { + match &n.kind { + NodeKind::Layer { printable: false, .. } => {} + NodeKind::Layer { .. } => { + let name = Some(n.display_name()).filter(|name| !name.is_empty()).unwrap_or_else(|| format!("<{}>", n.kind_label())); + top_level(n.children().map_or(&[], Vec::as_slice), &name, out); + } + NodeKind::Path { guide: true, .. } => {} + _ => { + if let Some(b) = n.visual_bounds().filter(|b| b.area().is_finite() && b.area() > 0.0) { + out.push((layer.to_string(), n.id, b)); + } + } + } + } + } + + // Where only spread edges or type differ, or only the art around the page, no object is + // replaced. + let (diff, regions) = view.solid_regions(doc, frame.rect, reflow)?; + if diff.solid <= MAX_SOLID_DIFFERENCE { + return None; + } + let mut objects = vec![]; + top_level(&doc.layers, "", &mut objects); + objects.retain(|(_, _, b)| !b.intersect(frame.rect).is_zero_area()); + if regions.len().saturating_mul(objects.len()) > MAX_PATCH_PAIRS { + return None; + } + // Each patch is the smallest object's that holds it. + let most = MAX_DIFFERING.min(objects.len() / 2); + let mut targets = BTreeSet::new(); + for region in regions { + let (_, id, _) = objects.iter().filter(|(_, _, b)| inside(region, *b)).min_by(|a, b| a.2.area().total_cmp(&b.2.area()))?; + targets.insert(*id); + if targets.len() > most { + return None; + } + } + if targets.is_empty() { + return None; + } + // A replaced wrap object keeps its wrap options, and the area type is wrapped around the page's + // art that replaces it. An object inside a replaced group can't keep them, so such a group isn't + // replaced. Neither is an object that holds type: the page's art that replaces it would hold + // type the page draws for other text objects too. + let mut wraps = vec![]; + for id in &targets { + let node = doc.node(*id)?; + let mut held = matches!(node.kind, NodeKind::Text(_)); + for child in node.children().into_iter().flatten() { + child.walk(&mut |n| held |= n.extras().wrap.is_some() || matches!(n.kind, NodeKind::Text(_))); + } + if held { + return None; + } + if let Some(wrap) = node.extras().wrap { + wraps.push((*id, wrap)); + } + } + let mut kept = doc.clone(); + let replace: Vec<(NodeId, Vec)> = targets.iter().map(|id| (*id, vec![])).collect(); + let mut copied = BTreeSet::new(); + replace_unread(&mut kept, frame, &replace, &[], threaded_frames, &mut copied).ok()?; + // A text object that stays draws its page type, so a copy of that type would draw it twice. + if shown.iter().flatten().any(|n| copied.contains(&n.id)) { + return None; + } + for (id, wrap) in wraps { + kept.node_mut(id)?.extras_mut().wrap = Some(wrap); + } + resolve_text_wrap(&mut kept).ok()?; + let note = compare(frame, view, &kept, reflow).ok()?; + let mut layers: Vec<(String, usize)> = vec![]; + for (layer, _, _) in objects.iter().filter(|(_, id, _)| targets.contains(id)) { + match layers.iter_mut().find(|(name, _)| name == layer) { + Some((_, count)) => *count += 1, + None => layers.push((layer.clone(), 1)), + } + } + let layers: Vec = layers.into_iter().map(|(layer, count)| format!("{layer} ({count})")).collect(); + Some((kept, note.into_iter().chain([format!("{DREW_DIFFERENTLY}: {}", layers.join(", "))]).collect())) +} + /// Copy page drawing IDs, including mask art outside the ordinary child tree. fn reid_page_art(doc: &mut Document, source: &Node) -> Node { let mut copy = doc.reid(source); @@ -814,8 +960,9 @@ fn slot_content( return Ok((made_only(infos, made, [own, pages[1]], notes), true)); } // The page's type is matched with every text object whose type isn't made, and weighed against - // where every made one draws: that has its limits. - let open = made.iter().filter(|m| m.is_none()).count(); + // where every made one draws: that has its limits (the slots of type that looks draw are within + // them, see `build_from`). + let open = infos.iter().zip(&made).filter(|(i, m)| i.look.is_none() && m.is_none()).count(); if open > MAX_SLOTS || infos.len().saturating_mul(pages[0].len()) > MAX_PAIRS { return Err("it has too many text objects".into()); } @@ -1001,6 +1148,23 @@ fn placed_files(nodes: &[Arc], out: &mut Vec) { } } +/// Is the editing data in units of `unit` points beside `page` ([`in_units`])? Its first artboard +/// `board` is, or, when that isn't the page in points, its art's box `art` is (a page cropped to the +/// art). +fn in_units_or_art(board: Option, art: Option, page: &Document, unit: f64) -> bool { + in_units(board, page, unit) || (!same_size(board, page) && in_units(art, page, unit)) +} + +/// The size in points of a unit of the editing data whose first artboard is `board` and whose +/// art's box is `art` (the document's coordinates), read beside `page`: `unit` (the page's +/// `UserUnit`) when the data is in those units ([`in_units_or_art`]), else `canvas_scale` (its +/// declared large canvas scale) when the data is at that scale; `None` when it is in points or +/// there is no page. +fn page_scale(board: Option, art: Option, page: Option<&Document>, unit: f64, canvas_scale: f64) -> Option { + let page = page?; + [unit, canvas_scale].into_iter().find(|&k| in_units_or_art(board, art, page, k)) +} + /// Does the editing data's first artboard `board` (in the document read from it: art space through /// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above /// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a @@ -1054,7 +1218,7 @@ fn drop_guides_off_boards(doc: &mut Document) { /// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their /// strokes, effects and pattern tiles, and with `styles` the strokes and effects of its graphic /// styles. -fn scale_document(doc: &mut Document, k: f64, styles: bool) { +pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let a = Affine::scale(k); let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() }; for layer in &mut doc.layers { diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index f382059b64..c20ed8383d 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -182,6 +182,12 @@ impl PageView { let printed = as_printed(b); let b = printed.as_ref().unwrap_or(b); let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); + let areas = self.counted_type(b, rb, reflow); + compared(&self.page, &y, &areas, &self.restyled) + } + + /// Where [`Self::difference`] counts `b`'s and the page's pixels as type, in the page's pixels. + fn counted_type(&self, b: &Document, rb: Rect, reflow: bool) -> Vec { let mut areas = vec![]; type_areas(&b.layers, rb, self.scale, &mut areas); if reflow { @@ -194,7 +200,7 @@ impl PageView { } else { areas.extend(&self.page_type); } - compared(&self.page, &y, &areas, &self.restyled) + areas } /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page @@ -210,6 +216,56 @@ impl PageView { let differing = self.page.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| differ_in_colour(p, q)).count(); differing as f64 / (f64::from(y.width) * f64::from(y.height)).max(1.0) } + + /// How `b` on `rb` draws differently from the page ([`Self::difference`]), and the boxes (in + /// `b`'s terms) around each patch of the pixels counted in its `solid` share (they and all + /// their neighbors differ, away from type), neighbors touching corner to corner counting as one patch. `None` when the two + /// drawings aren't the same size. + pub(super) fn solid_regions(&mut self, b: &Document, rb: Rect, reflow: bool) -> Option<(Difference, Vec)> { + let printed = as_printed(b); + let b = printed.as_ref().unwrap_or(b); + let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); + let areas = self.counted_type(b, rb, reflow); + let (diff, mut solid) = compared_with_solid(&self.page, &y, &areas, &self.restyled); + let (w, h) = (y.width as usize, y.height as usize); + if solid.len() != w * h { + return None; + } + // A pixel's size in points: [`comparison_render`] draws at `scale * samples`, rounds that + // raster to whole pixels and resizes it to `w` × `h`, so the two sizes can round differently. + let k = f64::from(self.samples); + let drawn = vectorcraft_render::raster_size(rb, self.scale * k).map_or((w as f64, h as f64), |(dw, dh)| (f64::from(dw), f64::from(dh))); + let pitch = (drawn.0 / (w as f64 * k * self.scale), drawn.1 / (h as f64 * k * self.scale)); + let mut regions = vec![]; + let mut stack = vec![]; + for start in 0..solid.len() { + if !solid.get(start).copied().unwrap_or(false) { + continue; + } + // The patch's pixels are cleared as they are taken, so each is taken once. + if let Some(s) = solid.get_mut(start) { + *s = false; + } + stack.push(start); + let (mut i0, mut j0, mut i1, mut j1) = (start % w, start / w, start % w, start / w); + while let Some(k) = stack.pop() { + let (i, j) = (k % w, k / w); + (i0, j0, i1, j1) = (i0.min(i), j0.min(j), i1.max(i), j1.max(j)); + for n in j.saturating_sub(1)..=(j + 1).min(h - 1) { + for m in i.saturating_sub(1)..=(i + 1).min(w - 1) { + if let Some(s) = solid.get_mut(n * w + m).filter(|s| **s) { + *s = false; + stack.push(n * w + m); + } + } + } + } + let at = |i: usize, j: usize| (rb.x0 + i as f64 * pitch.0, rb.y0 + j as f64 * pitch.1); + let ((x0, y0), (x1, y1)) = (at(i0, j0), at(i1 + 1, j1 + 1)); + regions.push(Rect::new(x0, y0, x1, y1)); + } + Some((diff, regions)) + } } /// How `a` on `ra` and `b` on `rb` (rectangles of the same size) draw differently ([`compared`]), @@ -233,8 +289,14 @@ pub(super) fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Diff /// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, /// and what the comparison looks for is art that is missing or out of place. pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference { + compared_with_solid(x, y, areas, skip).0 +} + +/// [`compared`], and which of the pixels (row by row) are counted in its `solid` share: none when +/// the drawings can't be compared. +fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> (Difference, Vec) { if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { - return Difference { any: 1.0, art: 1.0, solid: 1.0 }; + return (Difference { any: 1.0, art: 1.0, solid: 1.0 }, vec![]); } let (w, h) = (x.width as usize, x.height as usize); // Pixels in `skip` count as alike. @@ -245,12 +307,22 @@ pub(super) fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render: let typed = in_areas(areas, w, h); let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); // A pixel on the region's edge has neighbours outside it, which don't count as differing. - let solid = (1..h.saturating_sub(1)) - .flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) - .filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n)))) - .count(); + let mut solid = vec![false; w * h]; + for (i, j) in (1..h.saturating_sub(1)).flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j))) { + if !typed.get(j * w + i).copied().unwrap_or(true) + && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n))) + && let Some(s) = solid.get_mut(j * w + i) + { + *s = true; + } + } let area = (w as f64 * h as f64).max(1.0); - Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, art: art as f64 / area, solid: solid as f64 / area } + let shares = Difference { + any: differs.iter().filter(|d| **d).count() as f64 / area, + art: art as f64 / area, + solid: solid.iter().filter(|s| **s).count() as f64 / area, + }; + (shares, solid) } /// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 7f91438bec..a8bbd08373 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -242,9 +242,10 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { /// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy /// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. +/// Editing data written on a large canvas comes in at its declared scale: no page holds the size. pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard, false, false, 1.0) + build_from(ai::read(&data, true)?, &data, None, Page::Artboard, false, false, 1.0) } /// [`ai_alone`] for a `.ai` whose PDF part can't be read, its first page `page` (width and height) diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index 82dd6b0542..bac7108ac1 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -90,28 +90,173 @@ pub(super) fn make_slots( made } -/// Does the file's text document make the type of every slot that shows (at least one does)? +/// Does the file's text document make the type of every slot that shows (at least one does)? Type +/// that a look draws doesn't count: only the page has it. pub(super) fn readable_all(infos: &[SlotInfo], made: &[Option]) -> bool { - infos.iter().any(|i| i.shown) && infos.iter().zip(made).all(|(i, m)| !i.shown || m.is_some()) + let counted = |i: &SlotInfo| i.shown && i.look.is_none(); + infos.iter().any(counted) && infos.iter().zip(made).all(|(i, m)| !counted(i) || m.is_some()) } -/// Each slot's made type, where its slot is (the page's type that shows isn't put anywhere). Shown -/// made type with none of the page's type where it draws is weighed against the page (see -/// `prune_unseen`); the page's type that no made type takes, shown or not, is left out with a note -/// in `warn`. Slots that don't show and whose type isn't made take the page's type that doesn't -/// show (`pages`: the page's text objects that show and that don't), by painting order. +/// Each slot's made type, where its slot is (the page's type that shows isn't put anywhere, but for +/// the type of the copies that looks draw, see [`fill_look_slots`]). Shown made type with none of +/// the page's type where it draws is weighed against the page (see `prune_unseen`); the page's type +/// that no made type takes, shown or not, is left out with a note in `warn`. Slots that don't show +/// and whose type isn't made take the page's type that doesn't show (`pages`: the page's text +/// objects that show and that don't), by painting order. pub(super) fn made_only(infos: &[SlotInfo], made: Vec>, pages: [&[Vec>]; 2], warn: &mut Vec) -> Vec { let [shown, hidden] = pages; let mut content = made_content(infos, made); - match see_page_type(&mut content, shown) { + // The page's type where no made type draws, in the copies that looks draw, is theirs (past + // `MAX_PAIRS` comparisons it isn't looked for). + let looks = infos.iter().any(|i| i.shown && i.look.is_some()); + let mut taken = std::collections::HashSet::new(); + if looks && infos.len().saturating_mul(shown.len()) <= MAX_PAIRS { + let spelled: Vec = + content.iter().map(|c| if let Content::Made(node, ..) = c { spelling(&plain_text(node)) } else { String::new() }).collect(); + let made_type = |o: &[Arc]| covered(o, &content, |_| true) && has_made_words(o, &content, &spelled); + let mut spare: Vec>> = shown.iter().filter(|o| !made_type(o)).cloned().collect(); + fill_look_slots(infos, &mut content, &mut spare); + for c in &content { + if let Content::Page(parts) = c { + taken.extend(parts.iter().map(|n| n.id)); + } + } + } + let rest: Vec>>; + let untaken = if taken.is_empty() { + shown + } else { + rest = shown.iter().filter(|o| !o.iter().any(|n| taken.contains(&n.id))).cloned().collect(); + &rest + }; + match see_page_type(&mut content, untaken) { Some(0) => {} Some(_) => warn.push(PAGE_TYPE_LEFT_OUT.into()), None => warn.push(PAGE_TYPE_UNCHECKED.into()), } + drop_look_slots(infos, &mut content); fill_hidden(infos, &mut content, hidden); content } +/// Give the page's type in `spare` that lies in a copy that a layer's or group's look draws (at least +/// half of it in the box of the copy's other art, or round it as far as strokes round type reach; of +/// overlapping copies, the one that holds the most of it, else the smallest) to that copy's text +/// slots that show, by painting order, taking it out of `spare`. Type further out than that stays in +/// `spare`. The other text objects in a copy's group that have +/// nothing yet take its type with them, in their order. Where the page's type outside the copies is +/// too little for the other text objects that have nothing yet, a copy with more of the page's text +/// objects than slots leaves the extra ones in `spare` for them. The look's other slots are dropped +/// ([`drop_look_slots`]). +fn fill_look_slots(infos: &[SlotInfo], content: &mut [Content], spare: &mut Vec>>) { + // Each copy's group and box, the slots in it that show and have nothing yet, in order, and the + // page's text objects (by their place in `spare`) it holds. + // A group has one box: that of its art, or of the group around it. + type Copy = (Option, Rect, Vec, Vec); + let mut copies: Vec = vec![]; + let mut by_group: std::collections::HashMap, usize> = Default::default(); + for info in infos { + let Some(copy) = info.look.filter(|r| info.shown && !r.is_zero_area()) else { continue }; + by_group.entry(info.group).or_insert_with(|| { + copies.push((info.group, copy, vec![], vec![])); + copies.len() - 1 + }); + } + if copies.is_empty() || spare.is_empty() { + return drop_look_slots(infos, content); + } + for (k, (info, c)) in infos.iter().zip(content.iter()).enumerate() { + if let Some(copy) = by_group.get(&info.group).and_then(|i| copies.get_mut(*i)).filter(|_| info.shown && matches!(c, Content::Empty)) { + copy.2.push(k); + } + } + for (j, object) in spare.iter().enumerate() { + let Some(r) = extent(object) else { continue }; + let share = |copy: &Rect| copy.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(r).area() / r.area().max(1e-9); + let best = copies + .iter_mut() + .map(|c| (share(&c.1), c)) + .filter(|(s, c)| *s >= 0.5 && !c.2.is_empty()) + .max_by(|a, b| a.0.total_cmp(&b.0).then_with(|| b.1.1.area().total_cmp(&a.1.1.area()))); + if let Some((_, (.., held))) = best { + held.push(j); + } + } + // Copies with the same box share its type, by painting order. + let mut boxes: Vec<(Rect, Vec, Vec)> = vec![]; + for (_, copy, slots, held) in copies { + match boxes.iter_mut().find(|b| b.0 == copy) { + Some(b) => { + b.1.extend(slots); + b.2.extend(held); + } + None => boxes.push((copy, slots, held)), + } + } + // Other text objects that show and have nothing yet, outside the copies. + let in_copy: std::collections::BTreeSet = boxes.iter().flat_map(|b| b.1.iter().copied()).collect(); + let others: Vec = infos + .iter() + .zip(content.iter()) + .enumerate() + .filter(|(k, (i, c))| i.shown && i.look.is_none() && matches!(c, Content::Empty) && !in_copy.contains(k)) + .map(|(k, _)| k) + .collect(); + // How many of them the page's type outside the copies leaves without any. + let outside = spare.len().saturating_sub(boxes.iter().map(|b| b.2.len()).sum()); + let mut short = others.len().saturating_sub(outside); + let mut taken = vec![false; spare.len()]; + for (_, slots, held) in &mut boxes { + held.sort_unstable(); + // The page paints in the order of the layers: the extra ones go first to the other text objects + // before the copy, from the start, then to those after it, from the end. + let first = slots.first().copied().unwrap_or(0); + let extra = held.len().saturating_sub(slots.len()).min(short); + short -= extra; + let before = others.iter().filter(|k| **k < first).count().min(extra); + held.drain(..before); + held.truncate(held.len() - (extra - before)); + let objects: Vec>> = held.iter().filter_map(|j| spare.get(*j).cloned()).collect(); + for j in held.iter() { + if let Some(t) = taken.get_mut(*j) { + *t = true; + } + } + for (slot, parts) in slots.iter().zip(assign(&objects, slots.len())) { + if let Some(c) = content.get_mut(*slot).filter(|_| !parts.is_empty()) { + *c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect()); + } + } + } + let mut kept = taken.iter(); + spare.retain(|_| !kept.next().copied().unwrap_or(false)); + drop_look_slots(infos, content); +} + +/// Has made type that shows and draws where the page's text object `object` lies every word of it +/// ([`spelling`], each slot's in `spelled`), in any order? +fn has_made_words(object: &[Arc], content: &[Content], spelled: &[String]) -> bool { + let Some(r) = extent(object) else { return true }; + let near: Vec<&String> = content + .iter() + .zip(spelled) + .filter(|(c, _)| matches!(c, Content::Made(_, Some(region), _) if !region.intersect(r).is_zero_area())) + .map(|(_, s)| s) + .collect(); + object_words(object).iter().all(|w| near.iter().any(|s| s.contains(w.as_str()))) +} + +/// The text slots for type that a look draws and that have nothing have no type of their own to hold +/// (the page has it, or the text document of such type isn't read): they go, and aren't counted as +/// text objects that couldn't be read. +fn drop_look_slots(infos: &[SlotInfo], content: &mut [Content]) { + for (info, c) in infos.iter().zip(content) { + if info.look.is_some() && matches!(c, Content::Empty) { + *c = Content::Merged; + } + } +} + /// The letters and digits of `s`, in any script, in lower case: what is compared of the page's type /// and made type (the page may spell a mark another way). fn spelling(s: &str) -> String { @@ -176,7 +321,8 @@ fn see_page_type(content: &mut [Content], shown: &[Vec>]) -> Option String::new(), }) .collect(); - // Has made type that draws where `object` lies every word of it ([`spelling`]), in any order? - let made_words = |object: &[Arc], content: &[Content]| { - let Some(r) = extent(object) else { return true }; - let near: Vec<&String> = content - .iter() - .zip(&spelled) - .filter(|(c, _)| matches!(c, Content::Made(_, Some(region), _) if !region.intersect(r).is_zero_area())) - .map(|(_, s)| s) - .collect(); - object_words(object).iter().all(|w| near.iter().any(|s| s.contains(w.as_str()))) - }; let mut spare: Vec>> = vec![]; for (j, object) in shown.iter().enumerate() { match owner.get(j).copied().flatten() { @@ -252,7 +387,7 @@ pub(super) fn plan( }, // What lies where made type draws, with its words, is that type as the page draws it (in // another font, maybe, and in any script). - None if covered(object, &content, |_| true) && made_words(object, &content) => {} + None if covered(object, &content, |_| true) && has_made_words(object, &content, &spelled) => {} None if hidden_type(object) => {} None => spare.push(object.clone()), } @@ -264,6 +399,7 @@ pub(super) fn plan( join_pieces(parts, l); } } + fill_look_slots(infos, &mut content, &mut spare); // What is left of the page goes by painting order into the shown slots the file can't say. let loose: Vec = infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect(); diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index 1033effa9a..ed5b4c5a14 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -18,6 +18,10 @@ pub(super) const PAGE_TYPE_UNCHECKED: &str = pub(super) const LAYERS_UNREAD: &str = "the file's layers weren't read"; +/// Objects of the layers replaced with the page's art where they drew differently from it, +/// followed by the layers they are on. +pub(super) const DREW_DIFFERENTLY: &str = "objects that drew differently from the file's own page came in as its art"; + /// Does this import note say that the file has something the document doesn't (hidden text, art or /// layers that could not be read, or the page's type that no text object takes or may not take)? /// Writing the document over the file would lose it for good. @@ -27,7 +31,9 @@ pub fn is_loss(note: &str) -> bool { || note.ends_with("they came in as the page's art") || note == PAGE_TYPE_LEFT_OUT || note == PAGE_TYPE_UNCHECKED - || [ART_LEFT_OUT, LAYERS_UNREAD, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n)) + || [ART_LEFT_OUT, LAYERS_UNREAD, DREW_DIFFERENTLY, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY] + .iter() + .any(|n| note.starts_with(n)) } /// Most text objects in the layers, and most pieces of type in one place on the page, that are matched @@ -63,16 +69,64 @@ pub(super) struct SlotInfo { pub(super) shown: bool, /// Its own frame or path, where the file places it ([`ai::text_frames`]). pub(super) art_path: Option, + /// For type that a layer's or group's look draws ([`ai::is_look_slot`]): the box of the group + /// it is in, the copy of the art that the look draws (that of the group around it when it draws + /// nothing but type). + pub(super) look: Option, + /// The group it is in. + pub(super) group: Option, } /// The text slots of the layers and objects, in order. pub(super) fn collect_slots(nodes: &[Arc], shown: bool, out: &mut Vec) { + slots_in(nodes, shown, &mut vec![], &mut Default::default(), out); +} + +/// [`collect_slots`] for `nodes` in the innermost of `groups`. The boxes of groups are measured +/// only for type that looks draw, once each (`boxes`). +fn slots_in<'a>( + nodes: &'a [Arc], + shown: bool, + groups: &mut Vec<&'a Node>, + boxes: &mut std::collections::HashMap>, + out: &mut Vec, +) { for n in nodes { let shown = shown && n.visible; if let Some(story) = slot_of(n.name.as_deref()) { - out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None }); + // A group with nothing to draw but type is in the box of the group around it. + let look = ai::is_look_slot(n.name.as_deref()) + .then(|| groups.iter().rev().find_map(|g| *boxes.entry(g.id).or_insert_with(|| g.visual_bounds())).unwrap_or(Rect::ZERO)); + let group = groups.last().map(|g| g.id); + out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None, look, group }); } else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) { - collect_slots(c, shown, out); + groups.push(n); + slots_in(c, shown, groups, boxes, out); + groups.pop(); + } + } +} + +/// Past the limits on matching the page's type (`pieces` text objects) with the text slots `infos` of +/// `layers`, the slots of type that looks draw go: their copies' type is matched like any other of +/// the page's type. They also go when the page's type comes in as outlines (`outlined`), which leaves +/// them no type to hold. +pub(super) fn limit_look_slots(layers: &mut Vec>, infos: &mut Vec, pieces: usize, outlined: bool) { + let looks = infos.iter().filter(|i| i.look.is_some()).count(); + if looks > 0 && (outlined || looks > MAX_SLOTS || infos.len().saturating_mul(pieces) > MAX_PAIRS) { + drop_look_type_slots(layers); + infos.retain(|i| i.look.is_none()); + } +} + +/// Remove the text slots of type that looks draw ([`ai::is_look_slot`]) from `nodes` and their groups. +fn drop_look_type_slots(nodes: &mut Vec>) { + nodes.retain(|n| !ai::is_look_slot(n.name.as_deref())); + for n in nodes.iter_mut() { + if n.children().is_some_and(|c| !c.is_empty()) + && let Some(c) = Arc::make_mut(n).children_mut() + { + drop_look_type_slots(c); } } } @@ -512,6 +566,41 @@ pub(super) fn extent(parts: &[Arc]) -> Option { mod tests { use super::*; + /// Past the limits, the slots of type that looks draw go, in groups too, and other slots stay. + #[test] + fn look_type_slots_are_dropped_from_groups() { + let mut doc = Document::new(100.0, 100.0); + let mut slot = |name: &str| { + let mut n = Node::group(doc.alloc_id(), vec![]); + n.name = Some(name.into()); + Arc::new(n) + }; + let (look, own) = (slot("\u{0}text:look"), slot("\u{0}text0:0")); + let inner = Arc::new(Node::group(NodeId(90), vec![look.clone(), own.clone()])); + let mut nodes = vec![look, inner]; + let collected = |nodes: &[Arc]| { + let mut infos = vec![]; + collect_slots(nodes, true, &mut infos); + infos + }; + let stories = |infos: &[SlotInfo]| infos.iter().map(|i| (i.story, i.look.is_some())).collect::>(); + // Within the limits, they stay. + let mut infos = collected(&nodes); + limit_look_slots(&mut nodes, &mut infos, 10, false); + assert_eq!(stories(&infos), [(None, true), (None, true), (Some(0), false)]); + assert_eq!(stories(&collected(&nodes)), stories(&infos)); + // Three slots and a third of `MAX_PAIRS` pieces of the page's type, and more, are past them. + let mut past = nodes.clone(); + let mut past_infos = collected(&past); + limit_look_slots(&mut past, &mut past_infos, MAX_PAIRS / 3 + 1, false); + assert_eq!(stories(&past_infos), [(Some(0), false)]); + assert_eq!(stories(&collected(&past)), stories(&past_infos)); + // With the page's type as outlines, they go within the limits too. + limit_look_slots(&mut nodes, &mut infos, 10, true); + assert_eq!(stories(&infos), [(Some(0), false)]); + assert_eq!(stories(&collected(&nodes)), stories(&infos)); + } + /// A text slot that is a wrap object, filled with the page's type in two parts, stays one /// wrap object: a group of the parts. #[test] diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 52c098bb05..8f9cb55810 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -391,6 +391,14 @@ fn work_on_the_longest_dash_pattern_ends_soon() { } } +#[test] +fn a_chain_of_filters_over_the_program_is_bounded() { + let r = read_soon("0 0 10 10 rectfill currentfile 100 { 0 (x) /SubFileDecode filter } repeat pop 20 20 10 10 rectfill"); + assert!(r.warnings.iter().any(|w| w.contains("too many filters")), "{:?}", r.warnings); + let r = read_soon("0 0 10 10 rectfill currentfile 3 { 0 (x) /SubFileDecode filter } repeat pop 20 20 10 10 rectfill"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + #[test] fn procedures_bind_def_loops_and_dictionaries() { let r = read( diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index daeea4826a..9f7a8f4e72 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -824,6 +824,215 @@ fn top_level_placed_file_without_page_art_falls_back() { assert!(notes.iter().any(|n| n.contains("page art wasn't found")), "{notes:?}"); } +const BLACK: &str = "0 0 0 1"; +const YELLOW: &str = "0 0 1 0"; +const PAGE_CYAN: [f32; 4] = [1.0, 0.0, 0.0, 0.0]; +const PAGE_YELLOW: [f32; 4] = [0.0, 0.0, 1.0, 0.0]; + +/// A square of 16 points at (`x`, 10) in the format's operators, painted in CMYK `cmyk`. +fn square_16(x: u32, cmyk: &str) -> String { + format!("{cmyk} k\n{x} 10 m\n{} 10 L\n{} 26 L\n{x} 26 L\nf", x + 16, x + 16) +} + +/// A 100 × 100 page with a square of 16 points at each (`x`, `y`, CMYK) of `squares`: at `y` 74 +/// where [`square_16`] puts the layers' squares. +fn page_16(squares: &[(f64, f64, [f32; 4])]) -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + for (i, (x, y, [c, m, ye, k])) in squares.iter().enumerate() { + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(*x, *y, x + 16.0, y + 16.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(*c, *m, *ye, *k)); + let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint)); + let n = Node::path(d.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); + d.insert(Some(layer), i, n).unwrap(); + } + d +} + +/// Three squares on two layers, the middle one black in the layers and cyan on the page. +fn middle_differs() -> (String, Document) { + let front = format!("{}\n{}", square_16(40, BLACK), square_16(76, YELLOW)); + let art = native(&(layer("Back", true, &square_16(4, YELLOW)) + &layer("Front", true, &front))); + (art, page_16(&[(4.0, 74.0, PAGE_YELLOW), (40.0, 74.0, PAGE_CYAN), (76.0, 74.0, PAGE_YELLOW)])) +} + +#[test] +fn an_object_that_draws_differently_comes_in_as_the_page_art() { + let (art, page) = middle_differs(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Back", "Front"]); + assert_eq!(kinds(&doc.layers[0]), ["path"]); + assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]); + replacement_is_cyan(&doc, 1, 0); + let note = "objects that drew differently from the file's own page came in as its art: Front (1)"; + assert!(notes.iter().any(|n| n == note && crate::is_loss(n)), "{notes:?}"); + assert!(!notes.iter().any(|n| n.contains("weren't read")), "{notes:?}"); +} + +#[test] +fn two_differing_objects_on_different_layers_are_both_replaced() { + let back = format!("{}\n{}", square_16(4, BLACK), square_16(28, YELLOW)); + let front = format!("{}\n{}", square_16(52, YELLOW), square_16(76, BLACK)); + let art = native(&(layer("Back", true, &back) + &layer("Front", true, &front))); + let page = page_16(&[(4.0, 74.0, PAGE_CYAN), (28.0, 74.0, PAGE_YELLOW), (52.0, 74.0, PAGE_YELLOW), (76.0, 74.0, PAGE_CYAN)]); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Back", "Front"]); + assert_eq!(kinds(&doc.layers[0]), ["group[1]", "path"]); + assert_eq!(kinds(&doc.layers[1]), ["path", "group[1]"]); + replacement_is_cyan(&doc, 0, 0); + replacement_is_cyan(&doc, 1, 1); + let note = "objects that drew differently from the file's own page came in as its art: Back (1), Front (1)"; + assert!(notes.iter().any(|n| n == note), "{notes:?}"); +} + +/// The text wrap options of a 2-point offset, for the object before them. +const WRAP_OPTIONS: &str = "%_/ArtDictionary :\n%_0 /Bool (AI11TextWrapInvert) ,\n%_2 /Real (AI11TextWrapOffset) ,\n%_;\n%_\n"; + +#[test] +fn a_differing_wrap_object_keeps_its_wrap_options() { + let front = format!("{}\n{WRAP_OPTIONS}{}", square_16(40, BLACK), square_16(76, YELLOW)); + let art = native(&(layer("Back", true, &square_16(4, YELLOW)) + &layer("Front", true, &front))); + let page = page_16(&[(4.0, 74.0, PAGE_YELLOW), (40.0, 74.0, PAGE_CYAN), (76.0, 74.0, PAGE_YELLOW)]); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(kinds(&doc.layers[1]), ["group[1]", "path"]); + replacement_is_cyan(&doc, 1, 0); + let group = &doc.layers[1].children().unwrap()[0]; + assert_eq!(group.extras().wrap, Some(vectorcraft_doc::TextWrap { offset: 2.0, invert: false })); +} + +#[test] +fn a_differing_group_holding_a_wrap_object_still_falls_back() { + let group = os1_group(&format!("{}\n{WRAP_OPTIONS}", square_16(40, BLACK))); + let front = format!("{group}\n{}", square_16(76, YELLOW)); + let art = native(&(layer("Back", true, &square_16(4, YELLOW)) + &layer("Front", true, &front))); + let page = page_16(&[(4.0, 74.0, PAGE_YELLOW), (40.0, 74.0, PAGE_CYAN), (76.0, 74.0, PAGE_YELLOW)]); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(!notes.iter().any(|n| n.starts_with("objects that drew")), "{notes:?}"); +} + +#[test] +fn a_page_that_differs_everywhere_still_comes_in_as_its_pdf_part() { + let front = format!("{}\n{}", square_16(40, BLACK), square_16(76, BLACK)); + let art = native(&(layer("Back", true, &square_16(4, BLACK)) + &layer("Front", true, &front))); + let page = page_16(&[(4.0, 74.0, PAGE_CYAN), (40.0, 74.0, PAGE_CYAN), (76.0, 74.0, PAGE_CYAN)]); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("weren't read") && n.contains("differs from the page's")), "{notes:?}"); + assert!(!notes.iter().any(|n| n.starts_with("objects that drew")), "{notes:?}"); +} + +#[test] +fn a_page_object_outside_every_layer_object_still_falls_back() { + let front = format!("{}\n{}", square_16(40, YELLOW), square_16(76, YELLOW)); + let art = native(&(layer("Back", true, &square_16(4, YELLOW)) + &layer("Front", true, &front))); + let page = page_16(&[(4.0, 74.0, PAGE_YELLOW), (40.0, 74.0, PAGE_YELLOW), (76.0, 74.0, PAGE_YELLOW), (40.0, 20.0, PAGE_CYAN)]); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("differs from the page's")), "{notes:?}"); +} + +#[test] +fn a_differing_object_with_live_art_inside_falls_back() { + // The group reaches over the square between its own two. + let group = os1_group(&format!("{}\n{}", square_16(4, BLACK), square_16(40, YELLOW))); + let front = format!("{group}\n{}\n{}", square_16(22, YELLOW), square_16(76, YELLOW)); + let art = native(&layer("Front", true, &front)); + let page = page_16(&[(4.0, 74.0, PAGE_CYAN), (22.0, 74.0, PAGE_YELLOW), (40.0, 74.0, PAGE_YELLOW), (76.0, 74.0, PAGE_YELLOW)]); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(notes.iter().any(|n| n.contains("differs from the page's")), "{notes:?}"); +} + +#[test] +fn trusted_editing_data_keeps_a_differing_object() { + let (art, page) = middle_differs(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, true, 1.0); + assert!(read, "{notes:?}"); + assert_eq!(names(&doc), ["Back", "Front"]); + assert_eq!(kinds(&doc.layers[1]), ["path", "path"]); + assert!(notes.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{notes:?}"); + assert!(!notes.iter().any(|n| n.starts_with("objects that drew")), "{notes:?}"); +} + +#[test] +fn a_differing_object_on_an_unnamed_layer_is_counted_under_its_panel_name() { + let front = format!("{}\n{}", square_16(40, BLACK), square_16(76, YELLOW)); + let art = native(&(layer("Back", true, &square_16(4, YELLOW)) + &layer("", true, &front))); + let page = page_16(&[(4.0, 74.0, PAGE_YELLOW), (40.0, 74.0, PAGE_CYAN), (76.0, 74.0, PAGE_YELLOW)]); + let (_, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(read, "{notes:?}"); + let note = "objects that drew differently from the file's own page came in as its art: (1)"; + assert!(notes.iter().any(|n| n == note), "{notes:?}"); +} + +#[test] +fn at_most_64_differing_objects_come_in_as_the_page_art() { + // 130 squares of 4 points, the first `black` of them black in the layers and cyan on the page. + let grid = |black: usize| { + let at = |i: usize| (4 + 7 * (i % 13) as u32, 4 + 7 * (i / 13) as u32); + let art: String = (0..130) + .map(|i| { + let (x, y) = at(i); + if i < black { format!("{BLACK} k\n{}", square_path(x, y)) } else { square(x, y) } + }) + .collect::>() + .join("\n"); + let mut page = Document::new(100.0, 100.0); + let layer_id = page.layers[0].id; + for i in 0..130 { + let (x, y) = at(i); + let (x, y) = (f64::from(x), 96.0 - f64::from(y)); + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 4.0, y + 4.0)); + let color = + if i < black { vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0) } else { vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0) }; + let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid(color))); + let n = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); + page.insert(Some(layer_id), i, n).unwrap(); + } + crate::layered_ai(&private(&native(&layer("Grid", true, &art))), page, vec![], false, false, 1.0) + }; + let (_, notes, read) = grid(64); + assert!(read, "{notes:?}"); + let note = "objects that drew differently from the file's own page came in as its art: Grid (64)"; + assert!(notes.iter().any(|n| n == note), "{notes:?}"); + let (doc, notes, read) = grid(65); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(!notes.iter().any(|n| n.starts_with("objects that drew")), "{notes:?}"); +} + +#[test] +fn a_differing_object_under_the_type_of_a_text_object_that_stays_falls_back() { + // A black rectangle under area type whose frame reaches past it; the page draws the rectangle + // cyan, with the type on it. + let big = format!("{BLACK} k\n10 2 m\n60 2 L\n60 40 L\n10 40 L\nf"); + let doc = document_of(&[area_frame(34.0, 26.0, 95.0, 38.0)], &[story("Hi\\r", &[0], "/0 0", "/1 8.0")]); + let art = native_with_text(&(layer("Art", true, &format!("{big}\n{}", frame_object(0, 0))) + &layer("Top", true, &square_16(76, YELLOW))), &doc); + let mut page = page_16(&[(76.0, 74.0, PAGE_YELLOW)]); + let layer_id = page.layers[0].id; + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 60.0, 60.0, 98.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0)); + let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint)); + let square = Node::path(page.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); + page.insert(Some(layer_id), 0, square).unwrap(); + let style = vectorcraft_doc::CharStyle { size: 8.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(34.0, 69.0), "Hi", style); + let text = Node::new(page.alloc_id(), NodeKind::Text(Box::new(text))); + page.insert(Some(layer_id), 1, text).unwrap(); + let (doc, notes, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0); + assert!(!read, "{notes:?}"); + assert_eq!(names(&doc), ["Layer 1"]); + assert!(!notes.iter().any(|n| n.starts_with("objects that drew")), "{notes:?}"); +} + #[test] fn unread_object_on_second_artboard_takes_second_page_art() { let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ @@ -2175,6 +2384,286 @@ fn type_of_the_page_with_the_letters_of_two_made_types_where_it_lies_is_their_ty assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{:?}", r.warnings); } +/// A box from (`x0`, `y0`) to (`x1`, `y1`) in the format's operators, filled yellow. +fn filled_box(x0: u32, y0: u32, x1: u32, y1: u32) -> String { + format!("0 0 1 0 k\n{x0} {y0} m\n{x1} {y0} L\n{x1} {y1} L\n{x0} {y1} L\nf\n") +} + +/// The type in a drawn look (`1 /FreeUndo`): a story of another text document. +const LOOK_TYPE: &str = "/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n"; + +/// The text of each text object in `n`, and whether it is in a group whose box is `around`. +fn texts_in(n: &Node, around: (f64, f64), out: &mut Vec<(String, bool)>) { + for c in n.children().into_iter().flatten() { + match &c.kind { + NodeKind::Text(t) => { + let inside = n.visual_bounds().is_some_and(|b| (b.x0 - around.0).abs() < 0.5 && (b.y0 - around.1).abs() < 7.0); + out.push((t.plain_text(), inside)); + } + _ => texts_in(c, around, out), + } + } +} + +#[test] +fn a_copy_that_a_layers_look_draws_takes_the_pages_type_of_that_copy() { + // GUARDS: type in a copy that a layer's or group's look draws had no text object, so the page's + // type was dealt by painting order into other groups, and the copy had none. + // The look: a group with a copy of a box and its type, 50 to the right of the box itself. + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(60, 10, 90, 20)); + let original = format!("0 Ae\nu\n{}{}U\n", text_object(0), filled_box(10, 10, 40, 20)); + let art = native(&(layer("Art", true, &format!("{look}{original}")) + &layer("Words", true, &text_object(1)))); + let page = "0 0 1 0 setcmykcolor 60 10 moveto 90 10 lineto 90 20 lineto 60 20 lineto closepath fill \ + 10 10 moveto 40 10 lineto 40 20 lineto 10 20 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 61 12 moveto (Copy) show 11 12 moveto (Box) show 10 80 moveto (Other) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + // Art y 10..20 is 80..90 down the page. + let mut texts = vec![]; + texts_in(&r.document.layers[0], (60.0, 80.0), &mut texts); + assert_eq!(texts, [("Copy".to_string(), true), ("Box".to_string(), false)], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Other"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings); +} + +/// A `.ai` whose page (its PDF part) has a yellow box from (10, 70) to (50, 90), and the type +/// `text` at (12, 84) when there is one. +fn pdf_box_with_type(text: Option<&str>) -> Document { + let mut d = Document::new(100.0, 100.0); + let layer_id = d.layers[0].id; + let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 70.0, 50.0, 90.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0)); + let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint)); + let square = Node::path(d.alloc_id(), path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() }); + d.insert(Some(layer_id), 0, square).unwrap(); + if let Some(text) = text { + let style = vectorcraft_doc::CharStyle { size: 6.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(12.0, 84.0), text, style); + let id = d.alloc_id(); + d.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); + } + d +} + +#[test] +fn a_copy_that_a_look_draws_in_an_ai_file_takes_the_type_of_its_pdf_part() { + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 10, 50, 30)); + let art = native(&layer("Art", true, &look)); + let (d, w, read) = crate::layered_ai(&private(&art), pdf_box_with_type(Some("Copy")), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + let mut texts = vec![]; + texts_in(&d.layers[0], (10.0, 70.0), &mut texts); + assert_eq!(texts, [("Copy".to_string(), true)], "{w:?}"); + assert!(!w.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{w:?}"); + // With its type asked for as outlines, the copy has no type to hold, and the layers still come in. + let (d, w, read) = crate::layered_ai(&private(&art), pdf_box_with_type(None), vec![], true, false, 1.0); + assert!(read, "{w:?}"); + assert!(all_texts(&d).is_empty(), "{w:?}"); +} + +#[test] +fn the_pages_type_beside_a_copys_art_goes_by_painting_order() { + // The copy's box is that of its art: type beside it, not in it, goes to the other layer's text + // object, by painting order. + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 10, 50, 30)); + let art = native(&(layer("Art", true, &look) + &layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n"))); + let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 70 15 moveto (Beside) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + assert!(text_of(&r.document.layers[0]).is_empty() && all_texts(&r.document) == ["Beside"], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Beside"], "{:?}", r.warnings); +} + +#[test] +fn a_copy_in_the_place_of_made_type_with_its_words_takes_none_of_it() { + // A copy that a layer's look draws where made type draws, and the page's type there once, with + // the made type's words: that type is the made type as the page draws it, not the copy's. + let doc = text_document(&[("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]); + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(56, 4, 100, 30)); + let art = native_with_text(&(layer("Art", true, &look) + &layer("Words", true, &text_object(0))), &doc); + let page = "0 0 1 0 setcmykcolor 56 4 moveto 100 4 lineto 100 30 lineto 56 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 60 10 moveto (Other) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + assert_eq!(all_texts(&r.document), ["Other"], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Other"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings); +} + +#[test] +fn a_text_object_beside_a_copys_type_keeps_the_pages_type() { + // A group with type that a look draws and a text object of its own whose story isn't read: the + // page's type there goes to both by painting order, and to the text object when there is only + // one. + let group = format!("u\n{LOOK_TYPE}/AI11Text :\n0 /FreeUndo ,\n;\n{}U\n", filled_box(10, 10, 50, 30)); + let art = native(&layer("Art", true, &group)); + let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 22 moveto (Copy) show 12 15 moveto (Own) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + let mut texts = vec![]; + texts_in(&r.document.layers[0], (10.0, 70.0), &mut texts); + assert_eq!(texts, [("Copy".to_string(), true), ("Own".to_string(), true)], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings); + let page = page.replace("12 22 moveto (Copy) show ", ""); + let r = import(&eps(&page, &art)).unwrap(); + let group = &r.document.layers[0].children().unwrap()[0]; + assert_eq!(kinds(group), ["text", "path"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings); +} + +#[test] +fn a_copy_takes_no_more_of_the_pages_type_than_it_has_text_objects() { + // A copy with one text object, and a text object on another layer over it: the page's type there + // goes one to each, by painting order. + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 10, 50, 30)); + let art = native(&(layer("Art", true, &look) + &layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n"))); + let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 22 moveto (Copy) show 12 15 moveto (Over) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + let mut texts = vec![]; + texts_in(&r.document.layers[0], (10.0, 70.0), &mut texts); + assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Over"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings); + // With the other layer below, the page paints its type first. + let art = native(&(layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n") + &layer("Art", true, &look))); + let page = page.replace("(Copy) show 12 15 moveto (Over)", "(Over) show 12 15 moveto (Copy)"); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Words", "Art"], "{:?}", r.warnings); + let mut texts = vec![]; + texts_in(&r.document.layers[1], (10.0, 70.0), &mut texts); + assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Over"], "{:?}", r.warnings); + // With a layer below and one above, the copy's type is the middle one. + let words = |name: &str| layer(name, true, "/AI11Text :\n0 /FreeUndo ,\n;\n"); + let art = native(&(words("Below") + &layer("Art", true, &look) + &words("Above"))); + let page = page.replace("12 15 moveto (Copy) show", "12 15 moveto (Copy) show 12 12 moveto (Top) show"); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Below", "Art", "Above"], "{:?}", r.warnings); + let mut texts = vec![]; + texts_in(&r.document.layers[1], (10.0, 70.0), &mut texts); + assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings); + let of = |l: usize| text_of(&r.document.layers[l]).iter().map(|t| t.plain_text()).collect::>(); + assert_eq!((of(0), of(2)), (vec!["Over".to_string()], vec!["Top".to_string()]), "{:?}", r.warnings); +} + +#[test] +fn a_copy_takes_all_its_lines_when_every_other_text_object_is_made() { + // The page draws the copy's type as two text objects (two lines); the text object on the other + // layer is made from the text document. + let doc = text_document(&[("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]); + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(10, 40, 50, 70)); + let art = native_with_text(&(layer("Art", true, &look) + &layer("Words", true, &text_object(0))), &doc); + let page = "0 0 1 0 setcmykcolor 10 40 moveto 50 40 lineto 50 70 lineto 10 70 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 60 moveto (One) show 12 50 moveto (Two) show \ + /Helvetica findfont 14 scalefont setfont 60 10 moveto (Other) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + let mut texts = vec![]; + // Art y 40..70 is 30..60 down the page. + texts_in(&r.document.layers[0], (10.0, 30.0), &mut texts); + assert_eq!(texts, [("One".to_string(), true), ("Two".to_string(), true)], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings); + // The same with a text object elsewhere whose story isn't read, and its type on the page. + let art = native(&(layer("Art", true, &look) + &layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n"))); + let r = import(&eps(page, &art)).unwrap(); + let mut texts = vec![]; + texts_in(&r.document.layers[0], (10.0, 30.0), &mut texts); + assert_eq!(texts, [("One".to_string(), true), ("Two".to_string(), true)], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Other"], "{:?}", r.warnings); +} + +#[test] +fn copies_in_the_same_place_take_the_pages_type_by_painting_order() { + let copy = format!("u\n{LOOK_TYPE}{}U\n", filled_box(10, 10, 50, 30)); + let art = native(&layer("Art", true, &format!("0 Ae\nu\n{copy}{copy}U\n"))); + let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 22 moveto (One) show 12 15 moveto (Two) show"; + let r = import(&eps(page, &art)).unwrap(); + let look = &r.document.layers[0].children().unwrap()[0]; + let texts: Vec> = look.children().unwrap().iter().map(|g| text_of(g).iter().map(|t| t.plain_text()).collect()).collect(); + assert_eq!(texts, [["One"], ["Two"]], "{:?}", r.warnings); +} + +#[test] +fn overlapping_copies_that_a_look_draws_each_take_their_own_type() { + // Two copies whose boxes overlap (10..50 and 30..60 across), and a third that draws only type, + // in a group with a box of its own (10..90 across, 60..80 up). + let a = format!("u\n{LOOK_TYPE}{}U\n", filled_box(10, 10, 50, 30)); + let b = format!("u\n{LOOK_TYPE}{}U\n", filled_box(30, 10, 60, 30)); + let only_type = format!("u\n{}u\n{LOOK_TYPE}U\nU\n", filled_box(10, 60, 90, 80)); + let art = native(&layer("Art", true, &format!("0 Ae\nu\n{a}{b}{only_type}U\n"))); + let page = "0 0 1 0 setcmykcolor 10 10 moveto 50 10 lineto 50 30 lineto 10 30 lineto closepath fill \ + 30 10 moveto 60 10 lineto 60 30 lineto 30 30 lineto closepath fill \ + 10 60 moveto 90 60 lineto 90 80 lineto 10 80 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 6 scalefont setfont 12 15 moveto (Aye) show 34 15 moveto (Bee) show 20 70 moveto (Sea) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + // Art x 10 and 30 at y 10..30 are 70..90 down the page; the third copy's type is alone in its + // group, at x 20 and about 26 down. + for (around, text) in [((10.0, 70.0), "Aye"), ((30.0, 70.0), "Bee"), ((20.0, 26.0), "Sea")] { + let mut texts = vec![]; + texts_in(&r.document.layers[0], around, &mut texts); + let inside: Vec<&str> = texts.iter().filter(|(_, i)| *i).map(|(t, _)| t.as_str()).collect(); + assert_eq!(inside, [text], "{texts:?} {:?}", r.warnings); + } + assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings); +} + +#[test] +fn a_copy_over_made_type_takes_the_pages_type_with_other_words() { + // Made type at (60, 10), and a copy that a layer's look draws over it, whose type has other words. + let doc = text_document(&[("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]); + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(56, 4, 100, 30)); + let art = native_with_text(&(layer("Art", true, &look) + &layer("Words", true, &text_object(0))), &doc); + let page = "0 0 1 0 setcmykcolor 56 4 moveto 100 4 lineto 100 30 lineto 56 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 60 10 moveto (Other) show 60 12 moveto (Copy) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + let mut texts = vec![]; + // Art y 4..30 is 70..96 down the page. + texts_in(&r.document.layers[0], (56.0, 70.0), &mut texts); + assert_eq!(texts, [("Copy".to_string(), true)], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Other"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings); +} + +#[test] +fn copies_that_a_groups_look_draws_take_the_pages_type_beside_made_type() { + // A group whose style isn't read keeps its drawn look: the box and a copy of it 50 down and to + // the left, each with its type. The group itself follows on `%_` lines. Type on another layer + // is made from the text document. + let doc = text_document(&[("Box\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Other\r", 0, (8201.5, 8231.5), (0.0, 0.0), 0.0)]); + let look = format!("0 Ae\nu\nu\n{LOOK_TYPE}{}U\nu\n{LOOK_TYPE}{}U\nU\n", filled_box(54, 56, 100, 80), filled_box(4, 6, 50, 30)); + let own: String = format!("u\n{}{}U\n", text_object(0), filled_box(54, 56, 100, 80)).lines().map(|l| format!("%_{l}\n")).collect(); + let art = native_with_text(&(layer("Art", true, &format!("{look}{own}1 (Shadow) XW\n")) + &layer("Words", true, &text_object(1))), &doc); + let page = "0 0 1 0 setcmykcolor 54 56 moveto 100 56 lineto 100 80 lineto 54 80 lineto closepath fill \ + 4 6 moveto 50 6 lineto 50 30 lineto 4 30 lineto closepath fill \ + 0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Box) show 8.5 11.5 moveto (Box) show 60 10 moveto (Other) show"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings); + // Art y 6..30 is 70..94 down the page; y 56..80 is 20..44. + let mut texts = vec![]; + texts_in(&r.document.layers[0], (4.0, 70.0), &mut texts); + assert_eq!(texts, [("Box".to_string(), false), ("Box".to_string(), true)], "{:?}", r.warnings); + let mut texts = vec![]; + texts_in(&r.document.layers[0], (54.0, 20.0), &mut texts); + assert_eq!(texts, [("Box".to_string(), true), ("Box".to_string(), false)], "{:?}", r.warnings); + assert_eq!(text_of(&r.document.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Other"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("left out") || w.contains("order the page paints")), "{:?}", r.warnings); + // Without a page, the copies have no type to take, and their text objects aren't ones that + // couldn't be read. + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let mut texts = vec![]; + texts_in(&d.layers[0], (4.0, 70.0), &mut texts); + assert!(texts.is_empty(), "{texts:?} {notes:?}"); + assert_eq!(text_of(&d.layers[1]).iter().map(|t| t.plain_text()).collect::>(), ["Other"], "{notes:?}"); + assert!(!notes.iter().any(|n| n.contains("left out")), "{notes:?}"); +} + /// The file's text document is trusted where the page has type where it lays type out, whatever /// letters that type has: the type is the text document's, and the difference is noted as type. /// The page's type with other words is left out, with a note that is a loss. diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 2e8f5e03ee..290fe3c414 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -65,13 +65,13 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node, - NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind, + ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; -use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; -use crate::{latin, ps}; +use crate::{ai_ops, latin, ps}; mod encoding; mod prolog; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index b8055c32f9..ee8de7c16d 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -1,7 +1,7 @@ use super::writer::Writer; use super::{ - Affine, AreaOptions, BezPath, CharPosition, CharStyle, Color, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind, - TextObject, Write, latin, ps, stroke, + Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, + Write, latin, ps, stroke, }; /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops @@ -63,9 +63,7 @@ pub(super) enum Ink { }, } -pub(super) fn unit(v: f32) -> f32 { - if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } -} +pub(super) use crate::ai_ops::{average, unit}; /// Can `a` be drawn through (a finite, non-flat map)? pub(super) fn invertible(a: Affine) -> bool { @@ -189,10 +187,3 @@ pub(super) fn direct_fill(p: &Paint) -> bool { pub(super) fn direct_stroke(s: &StrokeLayer) -> bool { matches!(s.paint, Paint::Solid { .. }) && stroke::is_plain(s) } - -/// The average colour of a gradient's stops (in RGB). -pub(super) fn average(g: &Gradient) -> Color { - let n = g.stops.len().max(1) as f32; - let sum = g.stops.iter().map(|s| s.color.to_rgb()).fold([0.0f32; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]); - Color::rgb(unit(sum[0] / n), unit(sum[1] / n), unit(sum[2] / n)) -} diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 73a6e58426..1fe58d6595 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -3,11 +3,11 @@ use super::encoding::{ unit, writable, }; use super::{ - Affine, AnchorKind, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, - Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, - LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, LineCap, LineJoin, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, - Paint, ParaDirection, ParaStyle, PathData, Point, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, - TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, latin, ps, stroke, + Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, + GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, + LAYER_COLORS, LAYER_NAME, LIVE, LayerColor, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, + ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, + TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, }; pub(super) struct Writer<'a> { @@ -363,13 +363,9 @@ impl<'a> Writer<'a> { self.line(if sp.closed { "h\nW\nn" } else { "H\nW\nN" }); } - /// A guide (§6): `(N) *`, its path, `(N) *`. + /// A guide ([`ai_ops::guide`]). fn guide(&mut self, path: &PathData) { - for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { - self.line("(N) *"); - self.subpath(sp); - self.line("(N) *"); - } + ai_ops::guide(&mut self.body, self.to_art, path, false); } /// A path's fills and strokes: one object with one fill and a plain stroke over it where it @@ -528,71 +524,14 @@ impl<'a> Writer<'a> { self.line(if rule == FillRule::EvenOdd { "1 XR" } else { "0 XR" }); } - /// One subpath's points, in art space: `m`, then a line or curve to each point (lower case: the - /// point is smooth, upper case: a corner, §5.3.2), and for a closed one the segment back to the - /// start, which says whether the start is smooth or a corner (as the specification's examples - /// close paths: the start point again, then the painting operator). + /// One subpath's points, in art space ([`ai_ops::subpath`]). fn subpath(&mut self, sp: &SubPath) { - let Some(first) = sp.anchors.first() else { return }; - let xf = self.to_art; - let s = &mut self.body; - let pt = |s: &mut String, p: Point| { - let p = xf * p; - ps::push_nums(s, &[p.x, p.y]); - }; - pt(s, first.p); - s.push_str("m\n"); - let segment = |s: &mut String, a: &vectorcraft_geom::Anchor, b: &vectorcraft_geom::Anchor| { - let straight = a.h_out == a.p && b.h_in == b.p; - let smooth = b.kind == AnchorKind::Smooth; - if straight { - pt(s, b.p); - s.push_str(if smooth { "l\n" } else { "L\n" }); - } else { - pt(s, a.h_out); - pt(s, b.h_in); - pt(s, b.p); - s.push_str(if smooth { "c\n" } else { "C\n" }); - } - }; - for pair in sp.anchors.windows(2) { - if let [a, b] = pair { - segment(s, a, b); - } - } - if sp.closed - && sp.anchors.len() > 1 - && let Some(last) = sp.anchors.last() - { - segment(s, last, first); - } + ai_ops::subpath(&mut self.body, self.to_art, sp); } - /// The stroke's width, cap, join, miter limit and dashes: `w J j M d`. + /// The stroke's width, cap, join, miter limit and dashes ([`ai_ops::line_style`]). fn line_style(&mut self, st: &StrokeLayer) { - let cap = match st.cap { - LineCap::Butt => 0, - LineCap::Round => 1, - LineCap::Square => 2, - }; - let join = match st.join { - LineJoin::Miter => 0, - LineJoin::Round => 1, - LineJoin::Bevel => 2, - }; - let s = &mut self.body; - ps::push_num(s, st.width); - let _ = write!(s, " w {cap} J {join} j "); - ps::push_num(s, if st.miter_limit.is_finite() { st.miter_limit.max(1.0) } else { 10.0 }); - s.push_str(" M ["); - let dash = st.dash.as_ref().filter(|d| d.is_dashed()); - if let Some(d) = dash { - ps::push_nums(s, &d.pattern); - s.pop(); - } - s.push_str("] "); - ps::push_num(s, dash.map_or(0.0, |d| d.offset)); - s.push_str(" d\n"); + ai_ops::line_style(&mut self.body, st); } // ---------- colour ---------- diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index cbeed588e6..8fe04326c3 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -28,7 +28,12 @@ //! - `import`: the PostScript interpreter. //! - `print`: print jobs as PostScript files ([`print()`]), their pages drawn by `scene`. //! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups. +//! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries +//! ([`export_ai_editing`], [`ai_private_data`]). +//! - `ai_ops`: the path and line style operators those two write. +mod ai_editing; +mod ai_ops; mod import; mod latin; mod legacy_ai; @@ -37,6 +42,7 @@ mod ps; mod scene; mod tiff; +pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data}; pub use import::{ Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, family_style, import, import_trusting, import_with, is_loss, layered_ai, page, }; diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 118d3f0b91..12a7492e7f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -29,6 +29,11 @@ pub const EPS: f64 = 1e-9; /// Most line segments [`flatten`] makes of one curve, whatever its size and the tolerance. pub const MAX_FLATTEN_PER_CURVE: f64 = 4096.0; +/// A curve whose control polygon is longer than this is drawn as a line by [`flatten`]: kurbo +/// squares and cubes coordinates when it sizes its work, and with coordinates this large that +/// overflows to infinity and it reserves a buffer no machine has. (Far beyond any drawing.) +pub const MAX_FLATTEN_LENGTH: f64 = 1e60; + /// [`kurbo::flatten`], each curve's tolerance raised so that it makes at most about /// [`MAX_FLATTEN_PER_CURVE`] segments. kurbo sizes its work and a buffer from the curve's /// coordinates before producing anything, so a small curve with far-out handles (from a file, or @@ -60,7 +65,14 @@ pub fn flatten(path: impl IntoIterator, tolerance: f64, mut callb }; last = Some(to); let (Some(from), Some(length)) = (from, length) else { continue }; - if !length.is_finite() { + // A short polygon far from the origin still overflows kurbo's `p * (mt * 2.0)` products. + let far = |p: Point| !(p.x.abs() <= MAX_FLATTEN_LENGTH && p.y.abs() <= MAX_FLATTEN_LENGTH); + let controls_far = match el { + PathEl::QuadTo(a, _) => far(a), + PathEl::CurveTo(a, b, _) => far(a) || far(b), + _ => false, + }; + if length.is_nan() || length > MAX_FLATTEN_LENGTH || far(from) || far(to) || controls_far { callback(PathEl::LineTo(to)); continue; } @@ -198,6 +210,16 @@ mod tests { assert_eq!(ours, theirs); let nan = [PathEl::MoveTo(Point::ZERO), PathEl::QuadTo(Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0))]; assert_eq!(count(&nan, 0.25), 2); + // Finite coordinates whose squares overflow are a line to the end too. + let huge = [PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::new(1e200, 0.0), Point::new(-1e200, 1e200), Point::new(1.0, 0.0))]; + assert_eq!(count(&huge, 0.25), 2); + // A short polygon out near f64::MAX: a line, and every point it emits is finite. + let m = f64::MAX / 2.0; + let far = [PathEl::MoveTo(Point::new(m, m)), PathEl::CurveTo(Point::new(m, m), Point::new(m, m), Point::new(m, m))]; + let mut points = vec![]; + flatten(far.iter().copied(), 0.25, |e| points.push(e)); + assert_eq!(points.len(), 2); + assert!(points.iter().all(|e| matches!(e, PathEl::MoveTo(p) | PathEl::LineTo(p) if p.is_finite())), "{points:?}"); } #[test] diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs index 070048f38c..e35637468c 100644 --- a/crates/geom/src/projective.rs +++ b/crates/geom/src/projective.rs @@ -93,35 +93,36 @@ impl Homography { self.inverse().is_some() } - /// Whether every entry is finite and the determinant's magnitude is more than 1e-15 times that - /// of the largest of the six products it sums (a test that gives the same answer at any scale - /// of the map). Each product is formed as a mantissa product and an exponent sum, and all six - /// are divided by the power of two of the largest before they are summed, so the largest is at - /// least 1/8 and below 1 and none of them overflows. + /// Whether every entry is finite and [`significant_det`] holds for the map or for its + /// adjugate (the inverse times the determinant). Both tests give the same answer at any scale + /// of the map. The adjugate of the adjugate is the map times a number, so, apart from + /// rounding, a map passes when its inverse passes and fails when its inverse fails. fn has_significant_det(&self) -> bool { if !self.is_finite() { return false; } - let terms = self.det_terms(); - let Some(e) = terms.iter().filter(|(m, _)| *m != 0.0).map(|(_, e)| *e).max() else { - return false; - }; - let terms = terms.map(|(m, x)| ldexp(m, x - e)); - let largest = terms.iter().fold(0.0_f64, |k, v| k.max(v.abs())); - let d: f64 = terms.iter().sum(); - d.abs() > 1e-15 * largest + significant_det(self.det_terms()) || significant_det(split_det_terms(&self.adjugate())) } /// The six signed products the determinant sums, each as a mantissa product and an exponent /// sum. fn det_terms(&self) -> [(f64, i32); 6] { + split_det_terms(&self.0.map(|row| row.map(frexp))) + } + + /// The adjugate (the transposed matrix of cofactors), each entry as a mantissa and a power of + /// two. Each cofactor's two products are formed exactly before they are subtracted, so an + /// entry is within a few units in the last place of the exact cofactor of the stored numbers. + /// The entries must be finite. + fn adjugate(&self) -> [[(f64, i32); 3]; 3] { let f = self.0.map(|row| row.map(frexp)); - // The columns each of the six products takes from rows 0, 1 and 2, and its sign. - let cols = [(0, 1, 2, 1.0), (0, 2, 1, -1.0), (1, 0, 2, -1.0), (1, 2, 0, 1.0), (2, 0, 1, 1.0), (2, 1, 0, -1.0)]; - cols.map(|(a, b, c, sign)| { - let (a, b, c) = (f[0][a], f[1][b], f[2][c]); - (sign * a.0 * b.0 * c.0, a.1 + b.1 + c.1) - }) + let c = |r0: usize, c0: usize, r1: usize, c1: usize| split_minor(f[r0][c0], f[r1][c1], f[r0][c1], f[r1][c0]); + let neg = |(m, e): (f64, i32)| (-m, e); + [ + [c(1, 1, 2, 2), neg(c(0, 1, 2, 2)), c(0, 1, 1, 2)], + [neg(c(1, 0, 2, 2)), c(0, 0, 2, 2), neg(c(0, 0, 1, 2))], + [c(1, 0, 2, 1), neg(c(0, 0, 2, 1)), c(0, 0, 1, 1)], + ] } /// The same map with every entry divided by one power of two, chosen so that the largest of the @@ -162,26 +163,17 @@ impl Homography { } /// The adjugate divided by the determinant; `None` when an entry is not finite, the - /// determinant is 0, or the result has an entry that is not finite or a determinant of 0 (its - /// six products, each held as a mantissa and a power of two, sum to 0, as when a row underflows - /// to 0). Every cofactor and the determinant are held as a mantissa and a power of two, so an - /// entry of the result is lost only when that entry itself is beyond the range of `f64`. + /// determinant is 0, or the result has an entry that is not finite or fails + /// [`Self::has_significant_det`] (as when a row underflows to 0). Every cofactor and the + /// determinant are held as a mantissa and a power of two, so an entry of the result is lost + /// only when that entry itself is beyond the range of `f64`. fn adjugate_inverse(&self) -> Option { if !self.is_finite() { return None; } let f = self.0.map(|row| row.map(frexp)); let mul = |a: (f64, i32), b: (f64, i32)| (a.0 * b.0, a.1 + b.1); - let c = |r0: usize, c0: usize, r1: usize, c1: usize| { - let (a, b) = (mul(f[r0][c0], f[r1][c1]), mul(f[r0][c1], f[r1][c0])); - split_sum(&[a, (-b.0, b.1)]) - }; - let neg = |(m, e): (f64, i32)| (-m, e); - let adj = [ - [c(1, 1, 2, 2), neg(c(0, 1, 2, 2)), c(0, 1, 1, 2)], - [neg(c(1, 0, 2, 2)), c(0, 0, 2, 2), neg(c(0, 0, 1, 2))], - [c(1, 0, 2, 1), neg(c(0, 0, 2, 1)), c(0, 0, 1, 1)], - ]; + let adj = self.adjugate(); // The determinant expanded along the first row, as `det` sums it. let (dm, de) = split_sum(&[mul(f[0][0], adj[0][0]), mul(f[0][1], adj[1][0]), mul(f[0][2], adj[2][0])]); if dm == 0.0 { @@ -195,7 +187,7 @@ impl Homography { } let out = Self(out); // H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0. - (out.is_finite() && split_sum(&out.det_terms()).0 != 0.0).then_some(out) + out.has_significant_det().then_some(out) } /// `self ∘ other`: `other` first. @@ -217,19 +209,31 @@ impl Homography { c.is_finite().then_some(c) } + /// Whether every entry of `a` is finite and the determinant of its linear part has a magnitude + /// more than 1e-15 times the larger of the two products it sums (a test that gives the same + /// answer at any scale of `a`). + pub fn affine_is_invertible(a: Affine) -> bool { + Self::from_affine(a).has_significant_det() + } + /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it - /// maps from and to is moved by `a`). When the product overflows, every entry is formed with - /// its powers of two kept apart, and the product is returned divided by the largest magnitude - /// in its `w` row (the same map, with a `w` row whose largest entry is 1). `None` when `a` - /// isn't invertible, or when the product overflows and the result at that scale has an entry - /// that is not finite or is not invertible. + /// maps from and to is moved by `a`). When the product overflows, or underflows to a map that + /// is not invertible, every entry is formed with its powers of two kept apart, and the product + /// is returned divided by the largest magnitude in its `w` row (the same map, with a `w` row + /// whose largest entry is 1). `None` when `a` isn't invertible + /// ([`Self::affine_is_invertible`]), or when the result at that scale has an entry that is not + /// finite or is not invertible. `a⁻¹` is + /// [`Affine::inverse`] when `a`'s determinant and that inverse are finite, and + /// [`Self::inverse`] of `a` otherwise. pub fn conjugated(&self, a: Affine) -> Option { - if a.determinant().abs() < 1e-15 { + if !Self::affine_is_invertible(a) { return None; } - let (to, from) = (Self::from_affine(a), Self::from_affine(a.inverse())); + let to = Self::from_affine(a); + let inv = a.inverse(); + let from = if a.determinant().is_finite() && inv.is_finite() { Self::from_affine(inv) } else { to.inverse()? }; let c = to.then_after(self).then_after(&from); - if c.is_finite() { + if c.is_finite() && c.has_significant_det() { return Some(c); } // Each entry of the product is a sum of nine terms to[r][i]·self[i][j]·from[j][k]. Every @@ -299,6 +303,53 @@ fn frexp(v: f64) -> (f64, i32) { (f64::from_bits((bits & !(0x7ff << 52)) | (1022 << 52)), e + bias) } +/// The six signed products the determinant of `f` sums, each as a mantissa product and an exponent +/// sum. +fn split_det_terms(f: &[[(f64, i32); 3]; 3]) -> [(f64, i32); 6] { + // The columns each of the six products takes from rows 0, 1 and 2, and its sign. + let cols = [(0, 1, 2, 1.0), (0, 2, 1, -1.0), (1, 0, 2, -1.0), (1, 2, 0, 1.0), (2, 0, 1, 1.0), (2, 1, 0, -1.0)]; + cols.map(|(a, b, c, sign)| { + let (a, b, c) = (f[0][a], f[1][b], f[2][c]); + (sign * a.0 * b.0 * c.0, a.1 + b.1 + c.1) + }) +} + +/// Whether the sum of the six products in `terms` has a magnitude more than 1e-15 times that of +/// the largest of them. All six are divided by the power of two of the largest before they are +/// summed, so the largest is at least 1/8 and below 1 and none of them overflows. `false` when +/// every product is 0. +fn significant_det(terms: [(f64, i32); 6]) -> bool { + let Some(e) = terms.iter().filter(|(m, _)| *m != 0.0).map(|(_, e)| *e).max() else { + return false; + }; + let terms = terms.map(|(m, x)| ldexp(m, x - e)); + let largest = terms.iter().fold(0.0_f64, |k, v| k.max(v.abs())); + let d: f64 = terms.iter().sum(); + d.abs() > 1e-15 * largest +} + +/// `a·d − b·c` for numbers split as by [`frexp`], as a mantissa `0.5 ≤ |m| < 1` (or 0) and a power +/// of two. Each product is formed exactly as a rounded value and its rounding error, and the four +/// parts are summed with the error of the first subtraction kept, so the result is within a few +/// units in the last place of the exact value unless the two products agree to about 32 digits. +fn split_minor(a: (f64, i32), d: (f64, i32), b: (f64, i32), c: (f64, i32)) -> (f64, i32) { + let exact = |x: (f64, i32), y: (f64, i32), sign: f64| { + let hi = x.0 * y.0; + (sign * hi, sign * x.0.mul_add(y.0, -hi), x.1 + y.1) + }; + let parts = [exact(a, d, 1.0), exact(b, c, -1.0)]; + let Some(e) = parts.iter().filter(|p| p.0 != 0.0).map(|p| p.2).max() else { + return (0.0, 0); + }; + let [(h0, l0), (h1, l1)] = parts.map(|(h, l, x)| (ldexp(h, x - e), ldexp(l, x - e))); + // Knuth's two-sum: `s + err` is exactly `h0 + h1`. + let s = h0 + h1; + let t = s - h0; + let err = (h0 - (s - t)) + (h1 - t); + let (m, x) = frexp(s + (err + (l0 + l1))); + (m, e + x) +} + /// The sum of `m·2^e` over `terms`, in order, as a mantissa `0.5 ≤ |m| < 1` (or 0) and a power of /// two. Every term is first divided by the power of two of the largest nonzero one, so none of /// them overflows. @@ -566,6 +617,99 @@ mod tests { } } + #[test] + fn the_inverse_of_an_invertible_map_is_invertible() { + // The inverse's determinant is about 1e-16 of its largest product, but its adjugate is + // the map itself times a number. + let skew = Homography([[-1000.0, -1e-8, -1e-5], [0.0, 1e-4, -1e7], [1e-8, 0.0, 1e-8]]); + let inv = skew.inverse().unwrap(); + assert_eq!(Homography::from_array(inv.to_array()), Some(inv)); + let back = inv.inverse().unwrap_or_else(|| panic!("{inv:?}")); + // The same map as `skew`, to within the rounding of the stored inverse: each entry within + // 1e-6 of the largest magnitude in its row, and points map to within 1e-6 of where `skew` + // maps them. + let k = back.0[2][0] / skew.0[2][0]; + assert!(k > 0.0, "{k}"); + for (r, row) in back.0.iter().enumerate() { + let size = skew.0[r].iter().fold(0.0_f64, |k, v| k.max(v.abs())); + for (c, v) in row.iter().enumerate() { + assert!((v / k - skew.0[r][c]).abs() <= 1e-6 * size, "{back:?}"); + } + } + for p in [Point::new(3.0, -2.0), Point::new(-0.5, 7.0)] { + let q = skew.apply(p).unwrap(); + assert!(back.apply(p).unwrap().distance(q) <= 1e-6 * q.to_vec2().hypot(), "{p:?}"); + } + // Maps whose determinant is 0 are refused, though the cofactors of the first one cancel + // to about 2e-12 of their products: its rows are close to parallel and the third is the sum of + // the first two. + let two = pow2(40); + let near = + Homography([[two + 1.0, two + 3.0, two + 7.0], [two + 2.0, two + 5.0, two + 11.0], [2.0 * two + 3.0, 2.0 * two + 8.0, 2.0 * two + 18.0]]); + let flat = Homography([[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [0.25, 0.5, 1.0]]); + let times = |h: Homography, f: f64| Homography(h.0.map(|row| row.map(|v| v * f))); + for h in [near, flat] { + for f in [pow2(-900), pow2(-40), 1.0, pow2(40), pow2(900)] { + let s = times(h, f); + assert!(!s.is_invertible(), "{f} {s:?}"); + assert_eq!(s.inverse(), None, "{f}"); + assert_eq!(Homography::from_array(s.to_array()), None, "{f}"); + } + } + } + + #[test] + fn conjugating_does_not_depend_on_the_scale_of_the_affine() { + let unit = |h: Homography| { + let k = h.0[2].iter().fold(0.0_f64, |k, v| k.max(v.abs())); + Homography(h.0.map(|row| row.map(|v| v / k))) + }; + let m = h().0; + for s in [1e-200, 1e-8, 1.0, 1e8, 1e200] { + let t = Affine::scale(s); + let c = h().conjugated(t).unwrap_or_else(|| panic!("{s}")); + // The scale times h() times its inverse. + let want = unit(Homography([[m[0][0], m[0][1], s * m[0][2]], [m[1][0], m[1][1], s * m[1][2]], [m[2][0] / s, m[2][1] / s, m[2][2]]])); + for (v, w) in unit(c).to_array().iter().zip(want.to_array()) { + assert!((v - w).abs() <= 1e-12 * w.abs().max(1.0), "{s}: {c:?}"); + } + // At 1e-200 the w row's first two entries are about 1e197, so these points, whose w is + // about 1, count as on the horizon (see `apply`). + if s < 1e-8 { + continue; + } + for p in [Point::new(3.0, 7.0), Point::new(-40.0, 12.0)] { + let want = t * h().apply(p).unwrap(); + assert!(c.apply(t * p).unwrap().distance(want) <= 1e-9 * want.to_vec2().hypot(), "{s} {p:?}"); + } + } + // An affine whose columns are parallel is refused at every scale. + for s in [1e-200, 1e-8, 1.0, 1e8, 1e200] { + let flat = Affine::new([s, 2.0 * s, 2.0 * s, 4.0 * s, 5.0, 6.0]); + assert_eq!(h().conjugated(flat), None, "{s}"); + assert_eq!(h().conjugated(Affine::scale(s) * Affine::scale_non_uniform(1.0, 0.0)), None, "{s}"); + } + assert_eq!(h().conjugated(Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])), None); + assert_eq!(h().conjugated(Affine::scale(f64::INFINITY)), None); + } + + #[test] + fn conjugating_a_map_at_a_tiny_scale_gives_an_invertible_map() { + // Against these affines the first two rows of the direct product underflow to 0. + let tiny = Homography(Homography::IDENTITY.0.map(|row| row.map(|v| v * 5e-324))); + for s in [1e-200, 1e-8] { + let c = tiny.conjugated(Affine::scale(s)).unwrap_or_else(|| panic!("{s}")); + assert_eq!(Homography::from_array(c.to_array()), Some(c), "{s}"); + let k = c.0[2][2]; + for (r, row) in c.0.iter().enumerate() { + for (j, v) in row.iter().enumerate() { + let want = if r == j { k } else { 0.0 }; + assert!(k != 0.0 && (v - want).abs() <= 1e-12 * k.abs(), "{s}: {c:?}"); + } + } + } + } + #[test] fn a_map_at_a_tiny_scale_maps_points_as_at_unit_scale() { for f in [5e-324, 1e-300] { diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index 0e4b82e08e..33a1b081d9 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -75,7 +75,7 @@ pub(crate) fn checksum(header: &[u8]) -> u16 { fn flatten(bp: &BezPath, tol: f64) -> Vec<(Vec, bool)> { let mut out: Vec<(Vec, bool)> = vec![]; let mut cur: Vec = vec![]; - kurbo::flatten(bp.iter(), tol, |el| match el { + vectorcraft_geom::flatten(bp.iter(), tol, |el| match el { PathEl::MoveTo(p) => { if cur.len() > 1 { out.push((std::mem::take(&mut cur), false)); diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 2e7b1b793f..f61ac5e488 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -143,9 +143,20 @@ pub(crate) fn eps_for<'a>(paths: impl IntoIterator) -> Resul } } } - Ok((m * f64::EPSILON * 64.0).max(1e-6)) + let eps = (m * f64::EPSILON * 64.0).max(1e-6); + // Past this the sweep tolerance is wider than a document unit: features thinner than it collapse and the call + // would answer `Ok` with part of the art (or all of it) gone. Refuse instead, so callers keep the original. + if eps > MAX_SWEEP_EPS { + return Err(PathOpsError::Degenerate); + } + Ok(eps) } +/// The widest sweep tolerance [`eps_for`] accepts: one document unit, reached by coordinates of about 7e13. (The engine +/// refuses coordinates far below that, so this only guards the library's own callers; it does not follow the `precision` +/// a caller asks for.) +const MAX_SWEEP_EPS: f64 = 1.0; + fn el_points(el: &kurbo::PathEl) -> impl Iterator { use kurbo::PathEl::*; let v: [Option; 3] = match *el { @@ -329,6 +340,13 @@ impl Arrangement { self.contours_where(|w| pred(&self.mask(w))) } + /// [`Self::contours`] with a panic in the sweep's contour pass turned into an error, as `new` does for the + /// sweep itself (native builds only: on the web a panic aborts, whatever is caught here). Used by the calls that can return an error; the rest have none to return and let the engine's + /// guard report it. + pub(crate) fn try_contours(&self, pred: impl Fn(&[bool]) -> bool) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours(pred))).map_err(|_| PathOpsError::Degenerate) + } + /// All distinct non-empty coverage masks that occur next to some edge, sorted. pub(crate) fn distinct_masks(&self) -> Vec> { let mut set = BTreeSet::new(); @@ -670,7 +688,7 @@ pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, o /// Boolean operation with an explicit refit `precision`; errors on non-finite input. pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result { let arr = Arrangement::new(vec![(fill_bezpath(a), a_rule), (fill_bezpath(b), b_rule)])?; - let c = arr.contours(|m| op.apply(m[0], m[1])); + let c = arr.try_contours(|m| op.apply(m[0], m[1]))?; let tidy = Tidy { precision, ..arr.tidy }; Ok(all_contours_to_path(&c, &tidy)) } @@ -680,7 +698,7 @@ pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRul pub fn try_boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> Result { let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect(); let arr = Arrangement::new(bps)?; - Ok(all_contours_to_path(&arr.contours(pred), &arr.tidy)) + Ok(all_contours_to_path(&arr.try_contours(pred)?, &arr.tidy)) } /// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a @@ -816,4 +834,75 @@ mod tests { lens.close_path(); assert_eq!(tidy.repair(&lens), lens); } + + fn square(x: f64, y: f64, size: f64) -> PathData { + PathData::single(vectorcraft_geom::SubPath::new( + [(x, y), (x + size, y), (x + size, y + size), (x, y + size)] + .iter() + .map(|&(x, y)| vectorcraft_geom::Anchor::corner(Point::new(x, y))) + .collect(), + true, + )) + } + + #[test] + fn coordinates_wider_than_the_precision_are_refused_not_emptied() { + // At 1e15 the sweep tolerance is far above a 10 unit square: both squares used to vanish and the call said `Ok`. + let near = square(0.0, 0.0, 10.0); + let far = square(1e15, 1e15, 10.0); + for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Difference, BoolOp::Xor] { + assert_eq!( + try_boolean(&near, FillRule::NonZero, &far, FillRule::NonZero, op, DEFAULT_PRECISION), + Err(PathOpsError::Degenerate), + "{op:?}" + ); + } + assert_eq!(try_area(&far, FillRule::NonZero), Err(PathOpsError::Degenerate)); + assert_eq!(try_normalize(&far, FillRule::NonZero), Err(PathOpsError::Degenerate)); + // The infallible entry points keep answering with an empty path. + assert!(boolean(&near, FillRule::NonZero, &far, FillRule::NonZero, BoolOp::Union).is_empty()); + } + + #[test] + fn coordinates_near_the_float_limit_do_not_reach_the_sweep() { + // A 1e308 rectangle made linesweeper panic (a caught panic here, an abort on the web). + let huge = square(0.0, 0.0, 1e308); + let small = square(0.0, 0.0, 10.0); + assert_eq!(try_area(&huge, FillRule::NonZero), Err(PathOpsError::Degenerate)); + assert_eq!(try_boolean(&huge, FillRule::NonZero, &small, FillRule::NonZero, BoolOp::Union, DEFAULT_PRECISION), Err(PathOpsError::Degenerate)); + assert_eq!(try_unite_all(&[(&huge, FillRule::NonZero), (&small, FillRule::NonZero)]), Err(PathOpsError::Degenerate)); + } + + #[test] + fn large_but_sensible_coordinates_still_work() { + // Well inside the limit: far-out but sensible offsets. + for at in [1e5, 1e7] { + let u = try_boolean( + &square(at, at, 10.0), + FillRule::NonZero, + &square(at + 5.0, at, 10.0), + FillRule::NonZero, + BoolOp::Union, + DEFAULT_PRECISION, + ) + .unwrap(); + let a = try_area(&u, FillRule::NonZero).unwrap(); + assert!((a - 150.0).abs() < 1.0, "at {at}: area {a}"); + } + } + + #[test] + fn the_coordinate_limit_sits_where_the_tolerance_passes_a_unit() { + // 100 unit squares: tolerance 0.7 at 5e13 (kept), 1.4 at 1e14 (refused). + assert!(try_area(&square(5e13, 5e13, 100.0), FillRule::NonZero).is_ok()); + assert_eq!(try_area(&square(1e14, 1e14, 100.0), FillRule::NonZero), Err(PathOpsError::Degenerate)); + } + + #[test] + #[cfg(panic = "unwind")] + fn a_panic_in_the_contour_pass_becomes_an_error() { + let arr = Arrangement::new(vec![(fill_bezpath(&square(0.0, 0.0, 10.0)), FillRule::NonZero)]).unwrap(); + assert!(arr.try_contours(|m| m[0]).is_ok()); + assert_eq!(arr.try_contours(|_| panic!("simulated panic in the sweep")).err(), Some(PathOpsError::Degenerate)); + } } diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 2a39cccfa6..7d289e135b 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use kurbo::{CubicBez, PathSeg, Point, Rect, Vec2}; +use kurbo::{CubicBez, ParamCurve, PathSeg, Point, Rect, Vec2}; use vectorcraft_geom::path::measured_arclen; use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; @@ -628,9 +628,32 @@ fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) pub fn add_anchor_points(path: &PathData) -> PathData { let mut out = path.clone(); for sp in &mut out.subpaths { - for seg in (0..sp.segment_count()).rev() { - sp.insert_anchor(seg, 0.5); + let n = sp.segment_count(); + if n == 0 { + continue; } + // Built in one pass (inserting into the middle of the vector shifts the rest each time): + // anchor k lands at 2k and the new anchor of segment k at 2k + 1. + let mut v = Vec::with_capacity(sp.anchors.len() * 2); + for (k, a) in sp.anchors.iter().enumerate() { + v.push(*a); + if k < n { + v.push(*a); + } + } + for seg in 0..n { + let c = sp.segment(seg); + let (l, r) = (c.subsegment(0.0..0.5), c.subsegment(0.5..1.0)); + if sp.segment_is_line(seg) { + v[2 * seg + 1] = Anchor::corner(l.p3); + } else { + let at = (2 * seg + 2) % v.len(); + v[2 * seg].h_out = l.p1; + v[at].h_in = r.p2; + v[2 * seg + 1] = Anchor { p: l.p3, h_in: l.p2, h_out: r.p1, kind: AnchorKind::Smooth }; + } + } + sp.anchors = v; } out } diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index a78d8744cf..ec6b517f7a 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -119,10 +119,10 @@ pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> // winding, leaving faces uncovered that are really within `d` of the path. Every face of the // true offset is bounded by arrangement edges, so a contour is spurious iff a point deep inside // it lies closer than `d`: holes when growing, islands when insetting. - let src = path.to_bezpath(); + let src = std::cell::LazyCell::new(|| boxed_segments(&path.to_bezpath())); let drop_outer = delta < 0.0; let spurious: Vec = - c.contours().map(|k| k.outer == drop_outer && deep_point(&k.path).is_some_and(|p| dist_to(&src, p) < d * (1.0 - 1e-4) - 1e-6)).collect(); + c.contours().map(|k| k.outer == drop_outer && deep_point(&k.path).is_some_and(|p| any_within(&src, p, d * (1.0 - 1e-4) - 1e-6))).collect(); if !spurious.contains(&true) { return all_contours_to_path(&c, &Tidy::free(DEFAULT_PRECISION)); } @@ -208,9 +208,25 @@ fn deep_point(bp: &BezPath) -> Option { best.map(|(_, p)| p) } -fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 { +/// Each segment of `bp` with its bounding box. +fn boxed_segments(bp: &BezPath) -> Vec<(kurbo::Rect, kurbo::PathSeg)> { + use kurbo::ParamCurveExtrema; + bp.segments().map(|s| (s.bounding_box(), s)).collect() +} + +/// Whether a segment with bounding box `b` could lie within `r` of `p`: the box is inflated by `r` +/// and by slack for rounding in the box and in the curve evaluation, which grows with the coordinates. +fn box_may_reach(b: &kurbo::Rect, p: kurbo::Point, r: f64) -> bool { + let reach = r + 1e-6 + 1e-9 * p.x.abs().max(p.y.abs()); + p.x >= b.x0 - reach && p.x <= b.x1 + reach && p.y >= b.y0 - reach && p.y <= b.y1 + reach +} + +/// Whether some segment lies closer than `r` to `p`. A segment whose box is too far away can't, so +/// it is skipped without the costly nearest-point search, and the search stops at the first +/// segment that does. +fn any_within(segs: &[(kurbo::Rect, kurbo::PathSeg)], p: kurbo::Point, r: f64) -> bool { use kurbo::ParamCurveNearest; - bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt() + r > 0.0 && segs.iter().any(|(b, s)| box_may_reach(b, p, r) && s.nearest(p, 1e-6).distance_sq.sqrt() < r) } #[cfg(test)] @@ -219,6 +235,125 @@ mod tests { use super::*; + /// The check as it was: the nearest distance over every segment, then compared. + fn dist_to(bp: &BezPath, p: kurbo::Point) -> f64 { + use kurbo::ParamCurveNearest; + bp.segments().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt() + } + + struct Lcg(u64); + impl Lcg { + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + /// A coordinate on a coarse grid, so segments touch, coincide and nest often. + fn grid(&mut self, n: f64) -> f64 { + (self.next() * n).floor() + } + } + + #[test] + fn nearby_segment_check_agrees_with_the_full_scan() { + let mut rng = Lcg(7); + let (mut hits, mut skipped) = (0, 0); + for case in 0..400 { + let n = [3.0, 6.0, 20.0][case % 3]; + let mut bp = BezPath::new(); + for _ in 0..1 + (rng.next() * 12.0) as usize { + let pt = |rng: &mut Lcg| kurbo::Point::new(rng.grid(n), rng.grid(n)); + let a = pt(&mut rng); + bp.move_to(a); + match (rng.next() * 5.0) as u32 { + 0 => bp.line_to(a), // zero length + 1 => bp.line_to(pt(&mut rng)), + 2 => bp.quad_to(pt(&mut rng), pt(&mut rng)), + 3 => bp.curve_to(pt(&mut rng), pt(&mut rng), pt(&mut rng)), + _ => { + // A circle, touching or nested in others. + let r = 1.0 + rng.grid(n / 2.0); + bp.extend(kurbo::Circle::new(a, r).to_path(0.01)); + } + } + if rng.next() < 0.3 { + bp.close_path(); + } + } + // The same geometry far from the origin too, where the rounding slack matters. + let shift = [0.0, 1e6, 1e9][case % 3]; + let bp = kurbo::Affine::translate((shift, -shift)) * bp; + let segs = boxed_segments(&bp); + for _ in 0..40 { + // On the grid (on segments and their ends) and off it. + let p = if rng.next() < 0.5 { + kurbo::Point::new(rng.grid(n), rng.grid(n)) + } else { + kurbo::Point::new(rng.next() * (n + 2.0) - 1.0, rng.next() * (n + 2.0) - 1.0) + }; + let p = kurbo::Point::new(p.x + shift, p.y - shift); + let nearest = dist_to(&bp, p); + // Radii around the true distance (where a wrong skip would show), just outside a + // segment's box, and the usual kind. + let box_gap = + segs.iter().map(|(b, _)| (b.x0 - p.x).max(p.x - b.x1).max(b.y0 - p.y).max(p.y - b.y1).max(0.0)).fold(f64::INFINITY, f64::min); + for r in [ + nearest, + nearest * (1.0 - 1e-9), + nearest * (1.0 + 1e-9), + nearest + 1e-6, + box_gap + 5e-7, + box_gap + 2e-6, + 0.0, + -1.0, + 1e-7, + rng.next() * 3.0, + 1e6, + ] { + assert_eq!(any_within(&segs, p, r), nearest < r, "case {case} p {p:?} r {r} nearest {nearest}"); + hits += usize::from(nearest < r); + // Every segment the box test skips is really out of reach. + for (b, s) in &segs { + if r > 0.0 && !box_may_reach(b, p, r) { + skipped += 1; + use kurbo::ParamCurveNearest; + assert!(s.nearest(p, 1e-6).distance_sq.sqrt() >= r, "case {case} skipped a segment within {r} of {p:?}"); + } + } + } + } + } + assert!(hits > 1000 && skipped > 10_000, "{hits} {skipped}"); + assert!(!any_within(&[], kurbo::Point::ZERO, 1.0)); + } + + #[test] + fn growing_a_plate_with_many_holes_keeps_the_holes_that_stay() { + // A plate with a grid of round holes: growing by 1 closes the narrow ones (radius <= 1) + // and keeps the wide ones. + let mut plate = BezPath::new(); + plate.extend(kurbo::Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1)); + for i in 0..10 { + for j in 0..10 { + let r = if (i + j) % 2 == 0 { 0.8 } else { 4.0 }; + plate.extend(kurbo::Circle::new((10.0 + 20.0 * f64::from(i), 10.0 + 20.0 * f64::from(j)), r).to_path(0.01).reverse_subpaths()); + } + } + let out = offset_path(&PathData::from_bezpath(&plate), 1.0, Join::Round, 4.0); + // The outer contour and the 50 wide holes; the 50 narrow ones are gone. + assert_eq!(out.subpaths.len(), 1 + 50); + // The holes left are the wide ones, shrunk to radius 3 and still centred on their grid point. + let mut holes = 0; + for sp in out.subpaths.iter().skip(1) { + let b = PathData::single(sp.clone()).to_bezpath().bounding_box(); + let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + assert!((b.width() - 6.0).abs() < 0.05 && (b.height() - 6.0).abs() < 0.05, "{b:?}"); + let (i, j) = (((cx - 10.0) / 20.0).round(), ((cy - 10.0) / 20.0).round()); + assert!((cx - 10.0 - 20.0 * i).abs() < 0.05 && (cy - 10.0 - 20.0 * j).abs() < 0.05 && (i + j) % 2.0 != 0.0, "{b:?}"); + holes += 1; + } + assert_eq!(holes, 50); + } + #[test] fn stroke_region_unites_outlines_whatever_their_winding() { // A bar, and a square ring wound the other way that the bar runs into. diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index c3184a9fc6..587da33448 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -515,6 +515,21 @@ fn add_anchor_points_doubles() { assert!(r.anchors().any(|(_, _, a)| a.p == Point::new(5.0, 0.0))); } +/// The one-pass build gives what inserting a midpoint into each segment (last first) gives, open or closed. +#[test] +fn add_anchor_points_matches_inserting() { + let open = PathData::single(SubPath::new(circle(0.0, 0.0, 10.0).subpaths[0].anchors.clone(), false)); + for src in [circle(0.0, 0.0, 10.0), rect(0.0, 0.0, 10.0, 10.0), open] { + let mut want = src.clone(); + for sp in &mut want.subpaths { + for seg in (0..sp.segment_count()).rev() { + sp.insert_anchor(seg, 0.5); + } + } + assert_eq!(add_anchor_points(&src), want); + } +} + #[test] fn average_axes() { let p = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 4.0), Point::new(5.0, 8.0)], false)); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 8a8bd10e0b..9c52e19992 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -17,7 +17,8 @@ //! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve -//! Editing the native document rides along as an embedded file ([`editing()`]). Each page can +//! Editing the native document rides along as an embedded file ([`editing()`]), and the first +//! page can carry a `.ai` file's editing copy of the art ([`PrivateData`]). Each page can //! carry a thumbnail ([`Thumbnail`], drawn by the caller), and Fast Web View writes a linearised //! file (still linearised when encrypted). A PDF 1.3 file ([`PdfSettings::pdf13`]) must be flat: //! the app flattens the document first, and the writer refuses transparency that is left. @@ -63,6 +64,7 @@ mod output; mod pages; mod patch; mod pdfx; +mod piece_info; mod post; mod presets; mod print; @@ -79,6 +81,7 @@ pub use import::{ import, import_with_report, is_glyph_outlines, is_outlines_note, }; pub use pages::{IllustratorData, PageInfo, PdfInfo, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, info, is_postscript}; +pub use piece_info::{PRIVATE_CHUNK, PrivateData}; pub use post::{THUMBNAIL_SIZE, Thumbnail}; pub use presets::*; pub use print::*; @@ -102,6 +105,8 @@ pub struct PdfOptions { pub modified: Option, /// The native document (`.vectorcraft` bytes) Preserve Editing embeds. pub native: Option>, + /// The editing copy of the art for every page's `PieceInfo`, as a `.ai` file carries it. + pub private_data: Option, /// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer /// doesn't draw; [`page_areas`] says what each page shows). pub thumbnails: Vec, diff --git a/crates/pdf/src/piece_info.rs b/crates/pdf/src/piece_info.rs new file mode 100644 index 0000000000..60cd80228a --- /dev/null +++ b/crates/pdf/src/piece_info.rs @@ -0,0 +1,158 @@ +//! An editor's private data on every page: a page-piece dictionary (ISO 32000-1 §14.5, +//! `PieceInfo`) holding the editing copy of the art that `.ai` files carry, laid out as in the +//! `.ai` files users sent, the form [`crate::illustrator_data`] reads. +//! +//! - Every page dictionary gets `/PieceInfo << /Illustrator n 0 R >>`, the same object `n` on +//! each page, and its own `/LastModified` (which §14.5 requires with `PieceInfo`). A file with +//! several pages whose pages do not all refer to the one data dictionary opens in other apps as +//! a PDF, without the editing data. +//! - Object `n` is the data dictionary: `<< /LastModified (D:…) /Private m 0 R >>`. +//! - Object `m` holds `AIMetaData` (a stream of the data's header comments), `AIPrivateData1` … +//! `AIPrivateDataN` (the data in streams of [`PRIVATE_CHUNK`] bytes, the last one shorter), +//! `ContainerVersion`, `CreatorVersion`, `NumBlock` (N), `RoundtripStreamType` and +//! `RoundtripVersion`. Every stream has a `/Length` and no filter: the data comes compressed. + +use std::fmt::Write as _; + +use crate::PdfError; +use crate::linearize::{self, Parsed}; +use crate::patch::Patch; + +/// Bytes in each private data stream but the last. +pub const PRIVATE_CHUNK: usize = 1 << 16; + +/// The `PieceInfo` key the data dictionary is under. +const OWNER: &str = "Illustrator"; // brand-ok: the key the files use + +/// The private data a page-piece dictionary carries (see the module documentation). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PrivateData { + /// The data, as its streams hold it joined. + pub data: Vec, + /// What the `AIMetaData` stream holds. + pub metadata: Vec, + pub container_version: u32, + pub creator_version: u32, + pub roundtrip_version: u32, + pub roundtrip_stream_type: u32, +} + +/// `pdf` with `p` in every page's `PieceInfo`, dated `modified` (Unix seconds, UTC). +pub(crate) fn add(pdf: Vec, p: &PrivateData, modified: i64) -> Result, PdfError> { + let failed = |why: &str| PdfError::Write(format!("can't add the editing data for other apps: {why}")); + let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?; + if pages.is_empty() { + return Err(failed("a page can't be read")); + } + let dicts = pages.iter().map(|&n| xref.dict(&pdf, n).map(|(at, _)| at)).collect::>>(); + let dicts = dicts.ok_or_else(|| failed("a page can't be read"))?; + let mut patch = Patch::new(&xref); + let metadata = patch.add_object(stream(&p.metadata)); + let blocks: Vec = p.data.chunks(PRIVATE_CHUNK).map(|c| patch.add_object(stream(c))).collect(); + let mut private = format!("<< /AIMetaData {metadata} 0 R"); + for (i, n) in blocks.iter().enumerate() { + let _ = write!(private, " /AIPrivateData{} {n} 0 R", i + 1); + } + let _ = write!( + private, + " /ContainerVersion {} /CreatorVersion {} /NumBlock {} /RoundtripStreamType {} /RoundtripVersion {} >>", + p.container_version, + p.creator_version, + blocks.len(), + p.roundtrip_stream_type, + p.roundtrip_version + ); + let private = patch.add_object(private.into_bytes()); + let date = pdf_date(modified); + let owner = patch.add_object(format!("<< /LastModified {date} /Private {private} 0 R >>").into_bytes()); + for dict in dicts { + patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes()); + } + patch.apply(&pdf, &xref) +} + +/// A stream object of `bytes`, not filtered. +fn stream(bytes: &[u8]) -> Vec { + let mut s = format!("<< /Length {} >>\nstream\n", bytes.len()).into_bytes(); + s.extend_from_slice(bytes); + s.extend_from_slice(b"\nendstream"); + s +} + +/// `at` (Unix seconds) as a PDF date string in UTC (ISO 32000-1 §7.9.4), with the offset written +/// as hours and minutes, as in the files users sent. +fn pdf_date(at: i64) -> String { + let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(at); + format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}+00'00')") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{PdfOptions, export}; + use vectorcraft_doc::Document; + + fn data(len: usize) -> PrivateData { + PrivateData { + data: (0..len).map(|i| (i % 251) as u8).collect(), + metadata: b"%!PS-Adobe-3.0\n%%EndComments\n".to_vec(), + container_version: 12, + creator_version: 30, + roundtrip_version: 24, + roundtrip_stream_type: 2, + } + } + + #[test] + fn the_first_page_carries_the_data_in_streams_of_64_kib() { + let doc = Document::new(200.0, 100.0); + let p = data(PRIVATE_CHUNK * 2 + 10); + let opts = PdfOptions { private_data: Some(p.clone()), ..PdfOptions::uncompressed() }; + let pdf = export(&doc, &opts).unwrap(); + assert_eq!(crate::illustrator_data(&pdf, None).as_deref(), Some(&p.data[..])); + let text = String::from_utf8_lossy(&pdf); + for key in ["/AIPrivateData1 ", "/AIPrivateData2 ", "/AIPrivateData3 ", "/NumBlock 3 ", "/ContainerVersion 12 ", "/CreatorVersion 30 "] { + assert!(text.contains(key), "{key}"); + } + assert!(!text.contains("/AIPrivateData4")); + assert_eq!(text.matches("/LastModified (D:").count(), 2, "the page's and the data dictionary's"); + assert!(text.contains(&format!("<< /Length {PRIVATE_CHUNK} >>")) && text.contains("<< /Length 10 >>")); + } + + #[test] + fn every_page_refers_to_the_one_data_dictionary() { + let mut doc = Document::new(200.0, 100.0); + let mut second = doc.artboards[0].clone(); + (second.id, second.name, second.rect) = (2, "Back".into(), vectorcraft_geom::Rect::new(240.0, 0.0, 440.0, 100.0)); + doc.artboards.push(second); + let opts = PdfOptions { private_data: Some(data(10)), ..PdfOptions::uncompressed() }; + let pdf = export(&doc, &opts).unwrap(); + let Parsed { xref, pages, .. } = linearize::parse(&pdf).unwrap(); + assert_eq!(pages.len(), 2); + let owners: Vec = pages + .iter() + .map(|&n| { + let (at, end) = xref.dict(&pdf, n).unwrap(); + let page = String::from_utf8_lossy(&pdf[at..end]).into_owned(); + assert!(page.contains("/LastModified (D:"), "{page}"); + let i = page.find("/PieceInfo << /Illustrator ").expect("every page carries the data"); + page[i..].split(">>").next().unwrap().to_string() + }) + .collect(); + assert_eq!(owners[0], owners[1]); + assert_eq!(String::from_utf8_lossy(&pdf).matches("/AIPrivateData1 ").count(), 1, "the data is written once"); + } + + #[test] + fn without_the_data_the_page_has_no_piece_info() { + let pdf = export(&Document::new(200.0, 100.0), &PdfOptions::uncompressed()).unwrap(); + assert!(!String::from_utf8_lossy(&pdf).contains("/PieceInfo")); + assert_eq!(crate::illustrator_data(&pdf, None), None); + } + + #[test] + fn dates_are_in_utc() { + assert_eq!(pdf_date(0), "(D:19700101000000+00'00')"); + assert_eq!(pdf_date(1_793_000_000), "(D:20261026073320+00'00')"); + } +} diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index eda3b9a5a6..e735d079fe 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -1,5 +1,6 @@ //! What the export does to the file once it is written, in this order: the pages' thumbnails -//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised +//! (General › Embed page thumbnails), a `.ai` file's editing copy of the art +//! ([`crate::PrivateData`]), the seal of the editing data, fast web view (a linearised //! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes //! through between its two steps so that it stays linearised. Also here: what makes a file say it //! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, the file's @@ -214,6 +215,13 @@ pub(crate) fn finish(pdf: Vec, opts: &PdfOptions, editing: bool, warnings: & } (false, _) => pdf, }; + let pdf = match &opts.private_data { + Some(p) => { + let at = opts.modified.or(opts.created).or_else(vectorcraft_doc::metadata::output_time).unwrap_or(0); + crate::piece_info::add(pdf, p, at)? + } + None => pdf, + }; let pdf = if editing { crate::editing::seal(pdf)? } else { pdf }; if !set.fast_web_view { return crate::encrypt::protect(pdf, set); diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index d3a54f896c..21462e6b95 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -165,6 +165,10 @@ fn turns(set: &PrintSettings, (w, h): (f64, f64), flipped: bool) -> (Affine, (f6 (v, (w, h)) } +fn too_many_pages() -> PdfError { + PdfError::BadSetting(format!("the job would print more than {} pages", super::MAX_PAGES)) +} + /// The pages of `doc` (whose layers are those that print) as `set` lays them out, the tiles of /// each artboard when tiling, and warnings (art that doesn't fit the imageable area). pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec) -> Result<(Vec, Vec), PdfError> { @@ -174,6 +178,10 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec super::MAX_PAGES { + return Err(too_many_pages()); + } let what = artboard.map_or_else(|| "The art".to_string(), |i| format!("Artboard {}", i + 1)); if !(r.width() > 0.0 && r.height() > 0.0) { warnings.push(format!("{what} has no area and was left out")); @@ -297,6 +305,9 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec super::MAX_PAGES { + return Err(too_many_pages()); + } for &t in &pick { let w = window(t); let view = turn * Affine::translate((at.0 - w.x0, at.1 - w.y0)); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 019beddd73..a6b4d27842 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -239,6 +239,14 @@ fn tiles_cover_the_artboard_with_overlap() { assert!(preview(&d, &settings(json!({"scaling": "tileFull", "overlap": 400}))).is_err(), "overlap of half a tile"); } +#[test] +fn a_job_of_too_many_artboards_is_refused_while_laying_out() { + let mut d = doc(100.0, 100.0, &[]); + d.artboards.extend((2..=MAX_PAGES as u32 + 2).map(|i| artboard(i, 0.0))); + let e = preview(&d, &settings(json!({}))).unwrap_err().to_string(); + assert!(e.contains("more than 2000 pages"), "{e}"); +} + #[test] fn artboard_range_skip_blank_and_ignore() { let mut d = diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 29d5483d32..b49f5b8314 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -120,8 +120,15 @@ struct Cache { static CACHE: Mutex> = Mutex::new(None); +/// Cache units a path takes: its anchors, and one for each subpath's own allocation. fn anchors(p: &PathData) -> usize { - p.subpaths.iter().map(|sp| sp.anchors.len()).sum() + p.subpaths.iter().map(|sp| sp.anchors.len() + 1).sum() +} + +/// Cache units an entry takes: its input and result paths, and the serialized parameters and +/// paints its key owns (one unit per [`CACHE_ANCHORS`]'s 50 bytes an anchor stands for). +fn weight(key: &Key, out: Option<&PathData>) -> usize { + anchors(&key.path) + out.map_or(0, anchors) + (key.params.len() + key.paints.len()) / 50 + 1 } static ERRORS: Mutex>> = Mutex::new(None); @@ -168,7 +175,7 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill } note(id, &r); let out = r.ok(); - let size = anchors(&key.path) + out.as_ref().map_or(0, anchors); + let size = weight(&key, out.as_ref()); if size > CACHE_ANCHORS / 4 { return out; } @@ -178,7 +185,7 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill *cache = Cache::default(); } if let Some((old, old_out)) = cache.entries.insert(h, (key, out.clone())) { - cache.anchors = cache.anchors.saturating_sub(anchors(&old.path) + old_out.as_ref().map_or(0, anchors)); + cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref())); } cache.anchors += size; out diff --git a/crates/render/src/threads.rs b/crates/render/src/threads.rs index dfd6bf757d..1070c29c6a 100644 --- a/crates/render/src/threads.rs +++ b/crates/render/src/threads.rs @@ -49,6 +49,7 @@ pub fn default_threads() -> u16 { /// [`default_threads`] when automatic, on `cpus` processors of which the process may use `usable` /// (0 = all). +#[cfg(any(test, not(target_arch = "wasm32")))] fn automatic_threads(cpus: usize, usable: usize) -> u16 { let cores = if usable == 0 { cpus } else { cpus.min(usable) }; (cores.saturating_sub(1)).min(8) as u16 diff --git a/crates/scriptcheck/src/report.rs b/crates/scriptcheck/src/report.rs index 1c55ce1b41..b21feb20e6 100644 --- a/crates/scriptcheck/src/report.rs +++ b/crates/scriptcheck/src/report.rs @@ -370,14 +370,14 @@ impl<'l> Acc<'l> { pub fn problem(&mut self, code: &'static str, line: Option, file: Option<&str>, detail: Option<&str>) { if self.room(self.problems.len()) { let detail = detail.map(|d| self.echo(d)); - self.problems.push(Problem { code, line, file: file.map(str::to_string), detail }); + self.problems.push(Problem { code, line, file: file.map(|f| self.echo(f)), detail }); } } pub fn directive(&mut self, name: &'static str, value: &str, line: usize, form: &'static str, file: Option<&str>) { if self.room(self.directives.len()) { let value = self.echo(value); - self.directives.push(Directive { name, value, line, form, file: file.map(str::to_string) }); + self.directives.push(Directive { name, value, line, form, file: file.map(|f| self.echo(f)) }); } } @@ -394,14 +394,14 @@ impl<'l> Acc<'l> { version: part(t.version), locale: part(t.locale), line, - file: file.map(str::to_string), + file: file.map(|f| self.echo(f)), }; self.targets.push(target); } pub fn include(&mut self, value: &str, line: usize, file: Option<&str>, status: IncludeStatus, path: Option<&str>) { if self.room(self.includes.len()) { - let include = Include { value: self.echo(value), line, file: file.map(str::to_string), status, path: path.map(|p| self.echo(p)) }; + let include = Include { value: self.echo(value), line, file: file.map(|f| self.echo(f)), status, path: path.map(|p| self.echo(p)) }; self.includes.push(include); } } @@ -461,3 +461,21 @@ fn echo_cow(s: &str, max: usize) -> Cow<'_, str> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_file_of_a_row_is_cut_like_the_rest_of_the_echo() { + let limits = Limits::default(); + let mut acc = Acc::new(&limits); + let long = "d/".repeat(5000); + acc.problem("x", Some(1), Some(&long), None); + acc.directive("include", "v", 1, "form", Some(&long)); + acc.target("illustrator", 1, Some(&long)); + for file in [&acc.problems[0].file, &acc.directives[0].file, &acc.targets[0].file] { + assert_eq!(file.as_deref().map(|f| f.chars().count()), Some(limits.echo)); + } + } +} diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 3a75f25791..eac3b3c3f4 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -146,7 +146,7 @@ impl OtFeatures { /// Is `tag` (optionally prefixed with `-` or `+`) one this set understands? pub fn known_tag(tag: &str) -> bool { - let t = tag.trim_start_matches(['-', '+']); + let t = tag.strip_prefix(['-', '+']).unwrap_or(tag); matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t) } @@ -306,6 +306,9 @@ mod tests { assert!(OtFeatures::known_tag(t), "{t}"); } assert!(!OtFeatures::known_tag("ss21")); + for tag in ["--liga", "+-dnom", "-+ss01", "++liga"] { + assert!(!OtFeatures::known_tag(tag), "{tag}"); + } let o = OtFeatures::from_tags(["ss03", "pnum", "sups", "-liga"]); assert_eq!(o.to_tags(), vec!["-liga", "ss03", "pnum", "sups"]); assert_eq!(OtFeatures::from_tags(["ss03", "-ss03"]).to_tags(), Vec::::new(), "turned off again"); diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 506c3b1bf1..382b96da2f 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -3,6 +3,70 @@ use super::names::{localized, name}; use super::traits::{FaceKeys, FontTraits}; use super::{Arc, GlyphId, HashMap, Location, LocationRef, MetadataProvider, Size, StringId}; +/// The most glyphs one [`outline_points`] lookup reads, composite components included. +const MAX_OUTLINE_VISITS: usize = 65_536; + +/// Unhinted TrueType contour points in glyph order, including transformed composite components. +/// A point cap, a depth bound and `visits`, the number of glyphs the whole lookup may still read, +/// keep damaged fonts from making caret lookup unbounded. Callers start `visits` at +/// [`MAX_OUTLINE_VISITS`]. +fn outline_points(font: &skrifa::FontRef<'_>, gid: GlyphId, depth: usize, visits: &mut usize) -> Option> { + use skrifa::raw::TableProvider; + use skrifa::raw::tables::glyf::{Anchor, CompositeGlyphFlags, Glyph}; + + const MAX_DEPTH: usize = 16; + const MAX_POINTS: usize = 65_536; + if depth >= MAX_DEPTH { + return None; + } + *visits = visits.checked_sub(1)?; + let glyph = font.loca(None).ok()?.get(gid, &font.glyf().ok()?)?.into_glyph()?; + match glyph { + Glyph::Simple(simple) => { + if simple.num_points() > MAX_POINTS { + return None; + } + let points: Vec<_> = simple.points().map(|p| (f64::from(p.x), f64::from(p.y))).collect(); + (points.len() == simple.num_points()).then_some(points) + } + Glyph::Composite(composite) => { + let mut points = Vec::new(); + for component in composite.components() { + let child = outline_points(font, GlyphId::new(component.glyph.to_u32()), depth + 1, visits)?; + let t = component.transform; + let xx = f64::from(t.xx.to_f32()); + let xy = f64::from(t.xy.to_f32()); + let yx = f64::from(t.yx.to_f32()); + let yy = f64::from(t.yy.to_f32()); + let transformed: Vec<_> = child.into_iter().map(|(x, y)| (xx * x + xy * y, yx * x + yy * y)).collect(); + let (dx, dy) = match component.anchor { + Anchor::Offset { x, y } => { + let (x, y) = (f64::from(x), f64::from(y)); + // Each axis of a scaled offset scales by the length of its basis vector, as + // in skrifa's outline loader. UNSCALED_COMPONENT_OFFSET turns the scaling off. + let offset_flags = CompositeGlyphFlags::SCALED_COMPONENT_OFFSET | CompositeGlyphFlags::UNSCALED_COMPONENT_OFFSET; + if component.flags & offset_flags == CompositeGlyphFlags::SCALED_COMPONENT_OFFSET { + (x * xx.hypot(xy), y * yx.hypot(yy)) + } else { + (x, y) + } + } + Anchor::Point { base, component } => { + let (bx, by) = *points.get(usize::from(base))?; + let (cx, cy) = *transformed.get(usize::from(component))?; + (bx - cx, by - cy) + } + }; + if transformed.len() > MAX_POINTS.saturating_sub(points.len()) { + return None; + } + points.extend(transformed.into_iter().map(|(x, y)| (x + dx, y + dy))); + } + Some(points) + } + } +} + /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -244,11 +308,11 @@ impl FontFace { /// ), one between each two /// characters the glyph stands for, each an x in horizontal text and a y (up from the baseline) /// in vertical text. A coordinate with a variation index (format 3 in a variable font) takes its - /// delta from the `GDEF` item variation store at the face's location; a device table's - /// adjustments for pixel sizes are not applied. `None` when the font lists no carets for the - /// glyph, lists a number other than `count`, gives one by a contour point, or gives one a - /// variation index whose delta can't be read. - fn ligature_caret_coordinates(&self, gid: u32, count: usize) -> Option> { + /// delta from the `GDEF` item variation store at the face's location. Format 2 uses the + /// unhinted outline point at the font's default variation location. Device-table adjustments + /// for pixel sizes are not applied. `None` when the font lists no carets for the glyph, lists + /// a number other than `count`, or gives a coordinate that cannot be resolved. + fn ligature_caret_coordinates(&self, gid: u32, count: usize, vertical: bool) -> Option> { use skrifa::raw::TableProvider; use skrifa::raw::tables::gdef::CaretValue; use skrifa::raw::tables::layout::DeviceOrVariationIndex; @@ -280,17 +344,26 @@ impl FontFace { } } } - CaretValue::Format2(_) => None, + CaretValue::Format2(v) => { + // Raw glyf points have no variation deltas applied. Do not use one at a + // non-default location, where it could put the caret in the wrong place. + if self.location.coords().iter().any(|c| c.to_f32() != 0.0) { + return None; + } + let mut visits = MAX_OUTLINE_VISITS; + let point = outline_points(&font, GlyphId::new(gid), 0, &mut visits)?.get(usize::from(v.caret_value_point_index())).copied()?; + Some(if vertical { point.1 } else { point.0 }) + } }) .collect::>>()?; (!carets.is_empty()).then_some(carets) } /// The caret positions inside ligature glyph `gid` set across, in font units from its left /// edge, left to right: the x coordinates of the font's ligature caret list. `None` when the - /// font lists no carets for the glyph, lists a number other than `count`, gives one by a contour - /// point, or gives positions that are not increasing inside the glyph's advance. + /// font lists no carets for the glyph, lists a number other than `count`, or gives positions + /// that are not increasing inside the glyph's advance. pub fn ligature_carets(&self, gid: u32, count: usize) -> Option> { - let carets = self.ligature_caret_coordinates(gid, count)?; + let carets = self.ligature_caret_coordinates(gid, count, false)?; let advance = self.advance(gid); let inside = carets.iter().all(|x| *x > 0.0 && *x < advance) && carets.windows(2).all(|w| w[0] < w[1]); inside.then_some(carets) @@ -298,10 +371,10 @@ impl FontFace { /// The caret positions inside ligature glyph `gid` standing upright in vertical type, in font /// units down from the top of its cell, top to bottom: the y coordinates of the font's ligature /// caret list, for a cell `cell` long whose top is `top` above the baseline. `None` when the - /// font lists no carets for the glyph, lists a number other than `count`, gives one by a contour - /// point, or gives positions that are not increasing inside the cell. + /// font lists no carets for the glyph, lists a number other than `count`, or gives positions + /// that are not increasing inside the cell. pub fn vertical_ligature_carets(&self, gid: u32, count: usize, top: f64, cell: f64) -> Option> { - let carets = self.ligature_caret_coordinates(gid, count)?; + let carets = self.ligature_caret_coordinates(gid, count, true)?; let inside = carets.iter().all(|y| *y < top && *y > top - cell) && carets.windows(2).all(|w| w[0] < w[1]); inside.then(|| carets.iter().rev().map(|y| top - y).collect()) } diff --git a/crates/text/src/fontdb/names.rs b/crates/text/src/fontdb/names.rs index fe0e24cc0b..b4eb8711b8 100644 --- a/crates/text/src/fontdb/names.rs +++ b/crates/text/src/fontdb/names.rs @@ -76,9 +76,23 @@ pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option, id: StringId) -> Vec<(Option, String)> { - font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect() + let mut bytes = 0usize; + font.localized_strings(id) + .take(MAX_NAME_RECORDS) + .map(|s| (s.language().map(str::to_owned), s.to_string())) + .take_while(|(_, s)| { + bytes = bytes.saturating_add(s.len()); + bytes <= MAX_NAME_BYTES + }) + .filter(|(_, s)| !s.is_empty()) + .collect() } /// Whether a name-table language tag is English (Illustrator's "Show Font Names in English"). @@ -119,3 +133,17 @@ pub(super) fn norm_chars(s: &str) -> impl Iterator + '_ { pub(super) fn norm(s: &str) -> String { norm_chars(s).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_name_table_of_many_records_is_cut_short() { + let many: Vec<(u16, &str)> = (0..2000).map(|_| (1, "Short")).collect(); + let name = crate::test_fonts::name_table(&many).unwrap_or_default(); + let font = crate::fontdb::sfnt_of(&[(b"name", &name)]).unwrap_or_default(); + let font = skrifa::FontRef::new(&font).unwrap(); + assert_eq!(localized(&font, StringId::FAMILY_NAME).len(), MAX_NAME_RECORDS); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 9d7ed666ea..38688b8ce5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -382,6 +382,21 @@ impl Ctx<'_> { } else { 0.0 }; + // On a path, an upright glyph turns about its pen: its cell runs along the path between + // where the glyph's transform sets the cell's foot and its top, moved by the shaper's + // placement and a baseline shift, and slanted by the path's effect. + let (width, width_start) = match (upright && self.on_path, g.sy) { + (true, sy) if sy != 0.0 => { + // In font units, with the Proportional Metrics change to its length. + let (cell, top) = upright_extent(g); + let proportional = g.vpal.filter(|_| g.face.vertical_glyph(g.gid).is_some()).map_or(0.0, |a| a.advance) / sy; + let tangent = Vec2::new(angle.cos(), angle.sin()); + let along = |y: f64| (m * Point::new(0.0, y) - origin).dot(tangent); + let (start, end) = (along(cell + proportional - top), along(-top)); + if start.is_finite() && end > start { (end - start, start) } else { (width, width_start) } + } + _ => (width, width_start), + }; if let Some(art) = g.inline.as_ref().and_then(|ib| ib.art) { self.out.inlines.push(InlineGlyph { run: g.run, byte: g.byte, glyph: self.out.glyphs.len(), xf: m, bounds: m.transform_rect_bbox(art) }); } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 1d32036076..a49e187aed 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -166,7 +166,9 @@ pub struct PositionedGlyph { /// in text space: the font's placement of the glyph (`GPOS` x placement), less a leading space /// taken off, plus the manual kerning of right-to-left text, which moves the glyph right so that /// the space it adds lies on the glyph's left. 0 for a glyph standing upright or set across the - /// column in vertical type, and for an inline graphic. + /// column in vertical type, and for an inline graphic. An upright glyph in vertical type on a + /// path turns about its pen, and its cell starts one cell length before the cell's top: 0.12 em + /// before the pen without the font's vertical metrics. pub width_start: f64, } diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index ee42864c58..929ba50bc1 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -19,6 +19,175 @@ fn db_with(font: Option>) -> FontDb { db } +/// Put a contour-point caret on the existing test ligature without changing its outline. +fn contour_caret_font(point_index: u16) -> Vec { + use write_fonts::FontBuilder; + use write_fonts::tables::gdef::{CaretValue, Gdef, LigCaretList, LigGlyph}; + use write_fonts::tables::layout::CoverageFormat1 as WriteCoverage; + use write_fonts::types::{GlyphId16, Tag}; + + let base = ligature_caret_font(None).expect("test font builds"); + let font = skrifa::FontRef::new(&base).expect("test font parses"); + let gid = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + let mut builder = FontBuilder::new(); + let list = LigCaretList::new(WriteCoverage::new(vec![GlyphId16::new(gid)]).into(), vec![LigGlyph::new(vec![CaretValue::format_2(point_index)])]); + builder.add_table(&Gdef::new(None, None, Some(list), None)).expect("GDEF builds"); + for record in font.table_directory.table_records() { + let tag = Tag::new(&record.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(record.tag()).expect("base table reads").as_bytes()); + } + } + builder.build() +} + +#[test] +fn a_contour_point_caret_uses_its_unhinted_x_and_y() { + use skrifa::raw::TableProvider; + use skrifa::raw::tables::glyf::Glyph; + + let base = ligature_caret_font(None).expect("test font builds"); + let font = skrifa::FontRef::new(&base).expect("test font parses"); + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let outline = font.loca(None).expect("loca reads").get(skrifa::GlyphId::new(gid), &font.glyf().expect("glyf reads")).expect("glyph reads"); + let Glyph::Simple(simple) = outline.into_glyph().expect("glyph has an outline") else { panic!("W is simple") }; + let (point_index, point) = + simple.points().enumerate().find(|(_, p)| p.x > 0 && p.x < 500 && p.y > -500 && p.y < 1000).expect("W has an interior contour point"); + let db = db_with(Some(contour_caret_font(u16::try_from(point_index).unwrap()))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").unwrap(); + assert_eq!(face.ligature_carets(gid, 1), Some(vec![f64::from(point.x)])); + assert_eq!(face.vertical_ligature_carets(gid, 1, 1000.0, 1500.0), Some(vec![1000.0 - f64::from(point.y)])); + + let bad = db_with(Some(contour_caret_font(u16::MAX))); + assert_eq!(bad.face(LIGATURE_CARET_FAMILY, "Regular").unwrap().ligature_carets(gid, 1), None); +} + +/// `count` glyph ids of [`contour_caret_font`] other than the ligature's. +fn spare_gids(count: usize) -> Vec { + let ligature = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + (1..).filter(|g| *g != ligature).take(count).collect() +} + +/// [`contour_caret_font`], whose caret is the ligature's point 0, with its `glyf` and `loca` +/// replaced: each `(gid, data)` in `glyphs` gives that glyph's `glyf` data, and every other glyph +/// is empty. +fn glyf_caret_font(glyphs: &[(u16, Vec)]) -> Vec { + use skrifa::raw::TableProvider; + use write_fonts::FontBuilder; + use write_fonts::types::Tag; + + let base = contour_caret_font(0); + let font = skrifa::FontRef::new(&base).expect("test font parses"); + let num_glyphs = font.maxp().expect("maxp reads").num_glyphs(); + let short_loca = font.head().expect("head reads").index_to_loc_format() == 0; + let mut glyf = Vec::new(); + let mut offsets = vec![0usize]; + for gid in 0..num_glyphs { + if let Some((_, data)) = glyphs.iter().find(|(g, _)| *g == gid) { + glyf.extend(data); + // Keep every offset even for the short `loca` format. + if glyf.len() % 2 == 1 { + glyf.push(0); + } + } + offsets.push(glyf.len()); + } + let loca: Vec = if short_loca { + offsets.iter().flat_map(|o| u16::try_from(o / 2).expect("short offset").to_be_bytes()).collect() + } else { + offsets.iter().flat_map(|o| u32::try_from(*o).expect("long offset").to_be_bytes()).collect() + }; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"glyf"), glyf); + builder.add_raw(Tag::new(b"loca"), loca); + for record in font.table_directory.table_records() { + let tag = Tag::new(&record.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(record.tag()).expect("base table reads").as_bytes()); + } + } + builder.build() +} + +/// [`glyf_caret_font`] whose ligature is a composite of eight copies of a glyph that is itself +/// eight copies of the next, 15 levels down to a glyph with no contours. +fn composite_fan_caret_font() -> Vec { + const FAN: usize = 8; + let ligature = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + let levels = spare_gids(15); + let composite = |child: u16| { + let mut data = vec![0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0]; + for i in 0..FAN { + // ARGS_ARE_XY_VALUES, plus MORE_COMPONENTS on all but the last; byte offsets of 0. + let flags: u16 = if i + 1 < FAN { 0x0022 } else { 0x0002 }; + data.extend(flags.to_be_bytes()); + data.extend(child.to_be_bytes()); + data.extend([0, 0]); + } + data + }; + let mut glyphs = vec![(ligature, composite(levels[0]))]; + for (level, gid) in levels.iter().enumerate() { + match levels.get(level + 1) { + Some(child) => glyphs.push((*gid, composite(*child))), + // No contours, a zero bounding box and no instructions. + None => glyphs.push((*gid, vec![0; 12])), + } + } + glyf_caret_font(&glyphs) +} + +/// A contour-point caret on a composite's point uses the component offset the outline loader +/// uses. The ligature is one component, a glyph with a single point at (50, 0), turned a quarter +/// turn and scaled by 1.5 with a two-by-two matrix and placed at (100, 0). With +/// SCALED_COMPONENT_OFFSET the offset's x scales by the length of the x basis, 1.5, so the point +/// lands at x = 150; with UNSCALED_COMPONENT_OFFSET as well the offset stays (100, 0). +#[test] +fn a_contour_point_caret_on_a_composite_takes_its_scaled_offset() { + let gid = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + let [leaf] = spare_gids(1)[..] else { panic!("one spare glyph") }; + // One on-curve point at (50, 0) with word coordinates, and no instructions. + let mut point = vec![0, 1, 0, 0, 0, 0, 0, 50, 0, 0, 0, 0, 0, 0, 0x01]; + point.extend(50i16.to_be_bytes()); + point.extend(0i16.to_be_bytes()); + let composite = |offset_flags: u16| { + // ARG_1_AND_2_ARE_WORDS, ARGS_ARE_XY_VALUES and WE_HAVE_A_TWO_BY_TWO. + let flags = 0x0083 | offset_flags; + let mut data = vec![0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0]; + data.extend(flags.to_be_bytes()); + data.extend(leaf.to_be_bytes()); + data.extend(100i16.to_be_bytes()); + data.extend(0i16.to_be_bytes()); + // xx = 0, yx = 1.5, xy = -1.5, yy = 0 in F2Dot14. + for f2dot14 in [0x0000u16, 0x6000, 0xa000, 0x0000] { + data.extend(f2dot14.to_be_bytes()); + } + data + }; + let caret = |offset_flags: u16| { + let db = db_with(Some(glyf_caret_font(&[(gid, composite(offset_flags)), (leaf, point.clone())]))); + db.face(LIGATURE_CARET_FAMILY, "Regular").unwrap().ligature_carets(u32::from(gid), 1) + }; + // SCALED_COMPONENT_OFFSET, then with UNSCALED_COMPONENT_OFFSET too. + assert_eq!(caret(0x0800), Some(vec![150.0])); + assert_eq!(caret(0x1800), Some(vec![100.0])); +} + +/// A contour-point caret on a composite that expands to billions of glyphs with no points gives +/// no caret, and quickly, rather than reading every one of them. +#[test] +fn a_contour_point_caret_on_a_huge_empty_composite_gives_up() { + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let db = db_with(Some(composite_fan_caret_font())); + let carets = db.face(LIGATURE_CARET_FAMILY, "Regular").map(|face| face.ligature_carets(gid, 1)); + let _ = send.send(carets); + }); + let carets = receive.recv_timeout(std::time::Duration::from_secs(30)).expect("the caret lookup finishes"); + assert_eq!(carets, Some(None)); +} + fn point(text: &str, family: &str) -> TextObject { TextObject::point(Point::ZERO, text, CharStyle { font_family: family.into(), size: 100.0, ..CharStyle::default() }) } @@ -459,6 +628,66 @@ fn an_upright_ligature_takes_its_caret_down_its_cell() { assert!((at(900) - 0.5).abs() < 1e-9, "{}", at(900)); } +/// On a path, an upright ligature in vertical type turns about its pen, so its cell runs from +/// 0.12 em before the pen to 0.88 em after it, with or without the font's vertical metrics. The +/// inner stop lies at the middle of that cell, and the first character's selection ends there. +#[test] +fn an_upright_ligature_on_a_path_takes_its_stop_in_its_turned_cell() { + for (font, family) in [(ligature_caret_font(None), LIGATURE_CARET_FAMILY), (vertical_ligature_caret_font(900), VERTICAL_LIGATURE_CARET_FAMILY)] { + let db = db_with(font); + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((1000.0, 0.0)); + let mut t = point("fi", family); + t.vertical = true; + t.runs[0].style.standard_vertical_roman = true; + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let l = layout(&db, &t); + assert!(l.on_path); + let Some(g) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + assert_eq!(g.carets, vec![(1, 0.5)], "{family}"); + assert!((g.width - 100.0).abs() < 1e-9 && (g.width_start + 12.0).abs() < 1e-9, "{family}: {} {}", g.width, g.width_start); + let x = |b| caret_position(&l, b).0.x - g.origin.x; + assert!((x(1) - 38.0).abs() < 1e-6, "{family}: {}", x(1)); + let q = selection_quads(&l, 0, 1); + assert_eq!(q.len(), 1, "{q:?}"); + assert!(((q[0][1] - q[0][0]).hypot() - 38.0).abs() < 1e-6, "{family}: {q:?}"); + // A baseline shift moves the turned glyph along the path, and its stop with it. + let ink = g.outline.bounding_box().x0; + t.runs[0].style.baseline_shift = 10.0; + let l = layout(&db, &t); + let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let moved = s.outline.bounding_box().x0 - ink; + assert!(moved.abs() > 1.0, "{family}: the shift moves the glyph along the path: {moved}"); + let stop = caret_position(&l, 1).0.x - s.origin.x; + assert!((stop - 38.0 - moved).abs() < 1e-6, "{family}: {stop} against {}", 38.0 + moved); + // Horizontal Scale widens the glyph across the path; its stop stays inside the glyph. + t.runs[0].style.baseline_shift = 0.0; + t.runs[0].style.h_scale = 200.0; + let l = layout(&db, &t); + let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let bounds = s.outline.bounding_box(); + let stop = caret_position(&l, 1).0.x - s.origin.x; + assert!(bounds.x0 < stop && stop < bounds.x1 && stop > 0.0, "{family}: {stop} outside {bounds:?}"); + t.runs[0].style.h_scale = 100.0; + // Skew on a slanted path: the stop stays inside the drawn glyph along the path. + let mut slant = BezPath::new(); + slant.move_to((0.0, 0.0)); + slant.line_to((1000.0, 1000.0)); + t.runs[0].style.baseline_shift = 0.0; + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&slant), start: 0.0, end: None }; + t.path_effect = vectorcraft_doc::PathEffect::Skew; + let l = layout(&db, &t); + let Some(s) = l.glyphs.iter().find(|g| g.len == 2) else { panic!("f + i is one glyph: {:?}", l.glyphs) }; + let tangent = Vec2::new(s.angle.cos(), s.angle.sin()); + let ink = s.outline.elements().iter().filter_map(|el| el.end_point()).map(|p| (p - s.origin).dot(tangent)); + let (lo, hi) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), d| (lo.min(d), hi.max(d))); + let (a, b) = caret_position(&l, 1); + let stop = (a.midpoint(b) - s.origin).dot(tangent); + assert!(lo < stop && stop < hi, "{family}: {stop} outside {lo}..{hi}"); + } +} + /// Tracking that makes an upright ligature's advance negative leaves it upright: it keeps its /// caret down its cell, and its cell starts where it does without tracking, with or without the /// font's vertical metrics. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 15da644a9f..d80fef1859 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -28,7 +28,13 @@ fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result } } // Which rings hold which: each ring against the others, its edges walked only where its - // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges in all. + // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair + // whose bounds miss the point costs a step too: many small rings are a square of pairs). let too_complex = || String::from("The shape is too complex for a 3D effect"); let mut work = 0usize; let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); let mut held_by = vec![]; for (j, (o, b)) in out.iter().zip(bounds).enumerate() { + work = work.saturating_add(1); if j == i || !(b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1) { + if work > MAX_CONTAINMENT_EDGES { + return Err(too_complex()); + } continue; } work = work.saturating_add(o.len()); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index cdbfa7054c..0f78aa7123 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -732,3 +732,25 @@ fn nonzero_shapes_keep_the_middle_a_same_way_contour_encloses() { assert!(caps(&nested(false)) > caps(&nested(true)) + 1000.0); assert!(inflate(&nested(false), FillRule::NonZero, outer, Inflate::default(), front()).is_ok()); } + +#[test] +fn inflate_over_bounds_of_subnormal_size_makes_no_grid() { + let (p, _) = shape(&square(0.0, 0.0, 10.0)); + // 5e-324 divides to a zero cell; 1e-307 is a normal size whose cell (size / 48) is subnormal. + for size in [5e-324, 1e-307] { + let tiny = Rect::new(0.0, 0.0, size, size); + assert_eq!(inflate(&p, FillRule::EvenOdd, tiny, Inflate::default(), front()).unwrap().len(), 0, "{size}"); + } +} + +#[test] +fn many_small_rings_far_apart_are_too_complex() { + // 8,000 squares in a row: no square's bounds hold another's corner, yet each pair is a step. + let mut b = BezPath::new(); + for i in 0..8000 { + b.extend(square(i as f64 * 3.0, 0.0, 1.0)); + } + let (p, r) = shape(&b); + let e = rings(&p, FillRule::NonZero, r.width().max(r.height()), usize::MAX).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); +} diff --git a/crates/tools/src/dimension.rs b/crates/tools/src/dimension.rs index 2290f5d2fd..44bbbf44b2 100644 --- a/crates/tools/src/dimension.rs +++ b/crates/tools/src/dimension.rs @@ -352,13 +352,16 @@ impl Tool for DimensionTool { } PointerKind::Down => { if self.pending.is_some() && !self.dragging { - // The second click of a picked measurement places it. - return self.place(cx, ev.pos); + self.press = Some((ev.pos, None)); + return self.preview(cx, ev.pos); } self.press = Some((ev.pos, self.target(cx, ev.pos))); vec![] } PointerKind::Drag => { + if self.pending.is_some() && !self.dragging && self.press.is_some() { + return self.preview(cx, ev.pos); + } if self.pending.is_none() && let Some((start, Some(target))) = self.press.clone() && start.distance(ev.pos) > cx.tol(3.0) @@ -374,8 +377,11 @@ impl Tool for DimensionTool { if self.dragging { return self.place(cx, ev.pos); } + if self.pending.is_some() { + return if self.press.take().is_some() { self.place(cx, ev.pos) } else { vec![] }; + } match self.press.take() { - Some((start, target)) if self.pending.is_none() => self.click(cx, start, target, ev.mods), + Some((start, target)) => self.click(cx, start, target, ev.mods), _ => vec![], } } @@ -397,7 +403,12 @@ impl Tool for DimensionTool { return self.key(cx, ToolKey::Escape, Mods::default()); } self.press = None; - vec![] + if self.pending.is_some() && self.began { + self.began = false; + vec![Action::Cancel] + } else { + vec![] + } } fn deactivate(&mut self, cx: &ToolContext) -> Vec { self.key(cx, ToolKey::Escape, Mods::default()) @@ -507,4 +518,44 @@ mod tests { assert!(!t.busy()); assert!(t.pointer(&c, &ev(PointerKind::Up, 150.0, 60.0)).is_empty(), "the release places nothing"); } + + #[test] + fn a_dropped_placement_press_does_not_commit_the_dimension() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y); + let mut t = DimensionTool::default(); + for (x, y) in [(400.0, 400.0), (450.0, 420.0)] { + assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, x, y)).is_empty()); + } + assert!(t.pending.is_some()); + assert!(matches!(t.pointer(&c, &ev(PointerKind::Move, 470.0, 450.0)).first(), Some(Action::Begin(_)))); + assert!(matches!(t.pointer(&c, &ev(PointerKind::Down, 470.0, 450.0)).last(), Some(Action::Preview(_, _)))); + assert_eq!(t.drop_press(&c), vec![Action::Cancel]); + assert!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)).is_empty()); + assert!(t.pending.is_some(), "the dimension can still be placed"); + assert!(matches!(t.pointer(&c, &ev(PointerKind::Down, 480.0, 460.0)).last(), Some(Action::Preview(_, _)))); + let dragged = t.pointer(&c, &ev(PointerKind::Drag, 490.0, 470.0)); + assert!(matches!(dragged.last(), Some(Action::Preview(_, _))), "{dragged:?}"); + let placed = t.pointer(&c, &ev(PointerKind::Up, 490.0, 470.0)); + assert!(matches!(placed.last(), Some(Action::Commit)), "{placed:?}"); + } + + #[test] + fn placement_press_previews_without_a_prior_move() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = DimensionTool::default(); + for (x, y) in [(400.0, 400.0), (450.0, 420.0)] { + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)).is_empty()); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)).is_empty()); + } + assert!(t.pending.is_some()); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 470.0, 450.0)); + assert!(matches!(acts.first(), Some(Action::Begin(_))), "{acts:?}"); + assert!(matches!(acts.last(), Some(Action::Preview(_, _))), "{acts:?}"); + } } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index bf2edac343..ee00fdbe97 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -323,7 +323,7 @@ pub(crate) const MAX_STROKE_SAMPLES: usize = 100_000; /// `n / PREVIEW_ALL_UNTIL` samples. Each preview carries every sample so far, so previewing at /// every sample cost the square of the stroke's length (a gesture of many drags sent at once /// spent seconds); this way it is about `PREVIEW_ALL_UNTIL` per sample. -const PREVIEW_ALL_UNTIL: usize = 1000; +pub(crate) const PREVIEW_ALL_UNTIL: usize = 1000; /// Whether a drag that made the stroke `n` samples long previews it ([`PREVIEW_ALL_UNTIL`]). pub(crate) fn previews_at(n: usize) -> bool { diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index a9f3928ec0..eaa13cf422 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -90,7 +90,7 @@ impl Tool for BlendTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let options = |t: &mut Self| { - (t.first, t.chained) = (None, false); + (t.first, t.chained, t.before_press) = (None, false, None); vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))] }; // A first pick whose object is no longer in the document (deleted, or undone away) is @@ -157,7 +157,7 @@ impl Tool for BlendTool { /// click picks a new first object. The document and the selection stay as they are. fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { if key == crate::ToolKey::Escape { - (self.first, self.chained) = (None, false); + (self.first, self.chained, self.before_press) = (None, false, None); } vec![] } @@ -171,7 +171,7 @@ impl Tool for BlendTool { vec![] } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - (self.first, self.chained) = (None, false); + (self.first, self.chained, self.before_press) = (None, false, None); vec![] } /// Choosing the Blend tool again finishes the blend: it drops the object picked first and @@ -382,6 +382,28 @@ mod tests { assert!(create("mesh").unwrap().chosen_again(&c).is_empty()); } + #[test] + fn resetting_blend_during_a_press_survives_its_drop() { + let (d, a) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + for reset in 0..3 { + let mut t = BlendTool { first: Some((a, None)), chained: true, ..Default::default() }; + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty()); + match reset { + 0 => assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty()), + 1 => assert!(t.chosen_again(&c).is_empty()), + _ => assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, 450.0, 450.0)), + vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))], + ), + } + assert!(t.drop_press(&c).is_empty()); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 450.0, 450.0)).is_empty()); + assert_eq!((t.first, t.chained), (None, false)); + } + } + #[test] fn blend_tool_picks_again_when_its_first_pick_is_gone() { let (mut d, a) = doc_with_rect(); diff --git a/crates/tools/src/symbolism.rs b/crates/tools/src/symbolism.rs index bb04f7ab33..1618d6e7d6 100644 --- a/crates/tools/src/symbolism.rs +++ b/crates/tools/src/symbolism.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Point, Shape}; use crate::draw2::{FEEDBACK, GestureTool, points_json}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, MAX_STROKE_SAMPLES, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, previews_at}; /// Create a symbolism tool or the brush-aware Paintbrush (None = not one of ours). pub fn create(id: &str) -> Option> { @@ -26,6 +26,8 @@ pub fn create(id: &str) -> Option> { pub struct SymbolismTool { id: &'static str, points: Vec, + /// How many points the last preview had ([`previews_at`] thins previews of a long drag). + previewed: usize, active: bool, hover: Option, alt: bool, @@ -53,7 +55,19 @@ impl SymbolismTool { "symbolStyler" => "symbolStyler", _ => "symbolSprayer", }; - Self { id, points: vec![], active: false, hover: None, alt: false, diameter: 80.0, intensity: 5.0, density: 5.0, symbol: None, style: None } + Self { + id, + points: vec![], + previewed: 0, + active: false, + hover: None, + alt: false, + diameter: 80.0, + intensity: 5.0, + density: 5.0, + symbol: None, + style: None, + } } fn label(&self) -> &'static str { @@ -103,6 +117,19 @@ impl SymbolismTool { } } +impl SymbolismTool { + /// Ends the drag: what is committed is the last preview, so the whole drag is shown first. + fn finish(&mut self, cx: &ToolContext) -> Vec { + self.active = false; + let stale = (self.previewed != self.points.len()).then(|| { + let (c, v) = self.command(cx); + Action::Preview(c, v) + }); + self.points.clear(); + stale.into_iter().chain([Action::Commit]).collect() + } +} + impl Tool for SymbolismTool { fn id(&self) -> &'static str { self.id @@ -116,6 +143,7 @@ impl Tool for SymbolismTool { match ev.kind { PointerKind::Down => { self.points = vec![p]; + self.previewed = 1; self.active = true; self.alt = ev.mods.alt; let (c, v) = self.command(cx); @@ -124,18 +152,19 @@ impl Tool for SymbolismTool { PointerKind::Drag if self.active => { // Sprayer: thin the samples to a fraction of the brush; others: every few pixels. let step = if self.id == "symbolSprayer" { (self.diameter * 0.15).max(cx.tol(3.0)) } else { cx.tol(3.0) }; - if self.points.last().is_some_and(|l| l.distance(p) < step) { + // Each preview carries every point so far: a long drag stops growing and previews less often. + if self.points.len() >= MAX_STROKE_SAMPLES || self.points.last().is_some_and(|l| l.distance(p) < step) { return vec![]; } self.points.push(p); + if !previews_at(self.points.len()) { + return vec![]; + } + self.previewed = self.points.len(); let (c, v) = self.command(cx); vec![Action::Preview(c, v)] } - PointerKind::Up if self.active => { - self.active = false; - self.points.clear(); - vec![Action::Commit] - } + PointerKind::Up if self.active => self.finish(cx), _ => vec![], } } @@ -157,11 +186,9 @@ impl Tool for SymbolismTool { } vec![] } - fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + fn deactivate(&mut self, cx: &ToolContext) -> Vec { if self.active { - self.active = false; - self.points.clear(); - return vec![Action::Commit]; + return self.finish(cx); } vec![] } @@ -294,6 +321,39 @@ mod tests { assert!(!t.busy()); } + #[test] + fn a_long_drag_previews_less_often_and_commits_all_of_it() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let cx = cx(&d, &s, &p); + let mut t = SymbolismTool::new("symbolShifter"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + let n = 7 * crate::PREVIEW_ALL_UNTIL + 1; // not a point that previews + let previews = (1..n).filter(|&i| !t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0 * i as f64, 0.0)).is_empty()).count(); + assert!(previews < n * 3 / 4, "{previews} previews of {n} points"); + let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0)); + let last = up.iter().rev().nth(1); + assert!(matches!(last, Some(Action::Preview(_, v)) if v["points"].as_array().unwrap().len() == n), "{up:?}"); + assert_eq!(up.last(), Some(&Action::Commit)); + } + + #[test] + fn switching_tools_mid_drag_previews_the_whole_drag_before_committing() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let cx = cx(&d, &s, &p); + let mut t = SymbolismTool::new("symbolShifter"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + let n = 7 * crate::PREVIEW_ALL_UNTIL + 1; // not a point that previews + for i in 1..n { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0 * i as f64, 0.0)); + } + let a = t.deactivate(&cx); + assert!(matches!(a.first(), Some(Action::Preview(_, v)) if v["points"].as_array().unwrap().len() == n), "{a:?}"); + assert_eq!(a.last(), Some(&Action::Commit)); + assert!(!t.busy()); + } + #[test] fn adjust_tools_map_to_symbol_adjust() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 646dbd6384..2b5812cf8b 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -189,9 +189,8 @@ pub struct TypeTool { snap: DrawSnap, /// The press landed in the edited text: dragging selects. selecting: bool, - /// The caret and anchor from before a press in type, kept until its release; a dropped press - /// puts them back. - before_press: Option<(usize, usize)>, + /// This saves the edit target, caret, and anchor until the press ends. + before_press: Option, /// Click counting for double/triple click (position of the last click, count). clicks: (Option, u8), /// Styled copy of the last copied range (pasting the same plain text keeps its styles). @@ -211,6 +210,13 @@ pub struct TypeTool { cache: Mutex>, } +struct BeforePress { + editing: Option, + fresh: Option, + caret: usize, + anchor: usize, +} + impl TypeTool { pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize, vertical: bool) -> Option> { if !mods.alt { diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index 3c17411720..b0494a3e1a 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -638,6 +638,64 @@ fn a_dropped_press_keeps_the_type_in_editing() { assert_eq!((tool.editing, tool.sel()), (Some(id), (3, 3))); } +#[test] +fn a_dropped_press_on_other_type_restores_the_edit_target_and_selection() { + let (mut d, first, mut tool) = editing("First text"); + let second = d.alloc_id(); + let text = TextObject::point(Point::new(300.0, 100.0), "Second text", CharStyle { size: 20.0, ..Default::default() }); + d.insert(Some(d.layers[0].id), 1, Node::new(second, NodeKind::Text(Box::new(text)))).unwrap(); + tool.fresh = Some(first); + tool.caret = 5; + tool.anchor = 2; + let (mut selection, p) = (Selection::default(), paint()); + selection.set([first]); + let c = cx(&d, &selection, &p); + let target = TypeTool::text(&c, second).unwrap(); + let layout = vectorcraft_text::layout(FontDb::global(), target); + let (a, b) = vectorcraft_text::caret_position(&layout, 3); + let at = target.xf * a.midpoint(b); + let down = tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x + 0.1, at.y)); + assert!(down.contains(&Action::Exec("select.set".into(), json!({"ids": [second.0]})))); + assert_eq!(tool.editing, Some(second)); + selection.set([second]); + let c = cx(&d, &selection, &p); + let dropped = tool.drop_press(&c); + assert!(dropped.is_empty()); + assert_eq!((tool.editing, tool.caret, tool.anchor), (Some(first), 5, 2)); + assert!(tool.pointer(&c, &PointerEvent::new(PointerKind::Up, at.x + 0.1, at.y)).is_empty()); + assert_eq!(tool.editing, Some(first)); + selection.set([first]); + let typed = tool.text_input(&cx(&d, &selection, &p), "!"); + assert!(typed.iter().any(|action| matches!(action, Action::Preview(_, params) if params["id"] == first.0))); + let NodeKind::Text(first_text) = &mut d.node_mut(first).unwrap().kind else { panic!("text fixture") }; + first_text.runs.clear(); + let ended = tool.finish(&cx(&d, &selection, &p)); + assert!(ended.contains(&Action::Exec("text.discardEmpty".into(), json!({"id": first.0})))); +} + +#[test] +fn a_dropped_press_does_not_reselect_discarded_empty_type() { + let (mut d, first, mut tool) = editing(""); + tool.fresh = Some(first); + let second = d.alloc_id(); + let text = TextObject::point(Point::new(300.0, 100.0), "Second text", CharStyle { size: 20.0, ..Default::default() }); + d.insert(Some(d.layers[0].id), 1, Node::new(second, NodeKind::Text(Box::new(text)))).unwrap(); + let (mut selection, p) = (Selection::default(), paint()); + selection.set([first]); + let c = cx(&d, &selection, &p); + let target = TypeTool::text(&c, second).unwrap(); + let layout = vectorcraft_text::layout(FontDb::global(), target); + let (a, b) = vectorcraft_text::caret_position(&layout, 3); + let at = target.xf * a.midpoint(b); + let down = tool.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x + 0.1, at.y)); + assert!(down.contains(&Action::Exec("text.discardEmpty".into(), json!({"id": first.0})))); + d.remove(first).unwrap(); + selection.set([second]); + let dropped = tool.drop_press(&cx(&d, &selection, &p)); + assert!(dropped.is_empty()); + assert_eq!(tool.editing, None); +} + #[test] fn select_all_and_styled_paste() { let (d, _, mut tool) = editing("abc"); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 9014f540e6..8c3ddf07c9 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -29,11 +29,19 @@ impl Tool for TypeTool { fn claims_key(&self, _: &ToolContext, key: ToolKey) -> bool { key == ToolKey::Escape && self.editing.is_some() } - /// A dropped press drops only the selection drag or the type it was placing: the type being - /// edited stays in editing, with the caret and selection it had before the press. - fn drop_press(&mut self, _cx: &ToolContext) -> Vec { - if let Some((caret, anchor)) = self.before_press.take() { - (self.caret, self.anchor) = (caret, anchor); + /// If the original text still exists, a dropped press restores its edit target, caret, and anchor. + fn drop_press(&mut self, cx: &ToolContext) -> Vec { + let mut out = vec![]; + if let Some(before) = self.before_press.take() { + if self.editing != before.editing { + out.extend(self.finish(cx)); + if let Some(id) = before.editing.filter(|id| Self::text(cx, *id).is_some()) { + self.start_editing(id, before.caret); + self.remember(cx); + } + } + self.fresh = before.fresh.filter(|id| self.editing == Some(*id)); + (self.caret, self.anchor) = (before.caret, before.anchor); } self.selecting = false; self.clicks = (None, 0); @@ -41,16 +49,17 @@ impl Tool for TypeTool { self.held = None; self.drag = None; self.snap.clear(); - vec![] + out } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { + let before = super::BeforePress { editing: self.editing, fresh: self.fresh, caret: self.caret, anchor: self.anchor }; if let Some(byte) = self.hit_edited(cx, ev.pos) { let same = self.clicks.0.is_some_and(|q| (q - ev.pos).hypot() <= cx.tol(4.0)); let n = if same { self.clicks.1 + 1 } else { 1 }; self.clicks = (Some(ev.pos), n); - self.before_press = Some((self.caret, self.anchor)); + self.before_press = Some(before); let out = self.move_to(byte, ev.mods.shift); self.goal_x = None; if n >= 3 { @@ -66,7 +75,7 @@ impl Tool for TypeTool { // as it does in the type being edited. if let Some(out) = self.edit_at(cx, ev.pos) { self.selecting = true; - self.before_press = Some((self.caret, self.anchor)); + self.before_press = Some(before); return out; } self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None))); diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index 140697dd6a..62b2071806 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -27,7 +27,9 @@ pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) { /// it. With Shift held, the end stays on the 45° step from the Constrain Angle nearest the /// pointer's direction: `p` is projected onto the step, then snapped along it as the Line Segment /// tool snaps a Shift-drawn end. With Snap to Point alone, the point `p` snaps to (an anchor, or -/// in line with a ruler guide) is projected instead. +/// in line with a ruler guide) is projected instead. When the snapped point lies nearer another +/// step (the grid rounds it past the halfway angle) or behind the start, it is turned back onto +/// the pointer's step. pub(crate) fn end_point(cx: &ToolContext, a: Point, p: Point, shift: bool) -> Point { if !shift { return crate::guides::snap_draw(cx, p, &[]).0; @@ -42,7 +44,9 @@ pub(crate) fn end_point(cx: &ToolContext, a: Point, p: Point, shift: bool) -> Po let s = if to_point { crate::guides::snap_draw(cx, p, &[]).0 } else { p }; let q = a + u * (s - a).dot(u); let leave = crate::guides::Leave::segment(cx, a, true); - crate::guides::PointSnap::new(cx, || crate::guides::Targets::collect(cx.doc, &[], None)).snap_from(cx, q, Some(&leave)).0 + let r = crate::guides::PointSnap::new(cx, || crate::guides::Targets::collect(cx.doc, &[], None)).snap_from(cx, q, Some(&leave)).0; + let v = r - a; + if v.cross(u).abs() <= 1e-9 * v.hypot().max(1.0) && v.dot(u) >= 0.0 { r } else { a + u * v.hypot() } } impl Tool for MeasureTool { @@ -222,6 +226,51 @@ mod tests { assert!((o["dy"].as_f64().unwrap() + 100.0).abs() < 1e-9, "{o}"); } + /// With a Constrain Angle of 30°, the grid point a Shift-drag's end rounds to can lie nearer + /// another step; the end stays on the step nearest the pointer's direction. + #[test] + fn shift_drag_keeps_its_step_after_the_grid_rounds_it() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + cx.snap_to_grid = true; + cx.constrain_angle = 30.0; + let shift = Mods { shift: true, ..Mods::default() }; + let mut t = MeasureTool::default(); + // (5, -3) points 31° up, so the step is 30°; the end lands at about (5.05, -2.92), which + // the 9 pt grid rounds to (9, 0), nearer the -15° step. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 5.0, -3.0).with_mods(shift)); + let o = t.options(); + assert!((o["angle"].as_f64().unwrap() - 30.0).abs() < 1e-9, "{o}"); + } + + /// With Snap to Point alone, an anchor behind the start can pull the projected end behind it; + /// the end is turned back onto the pointer's step instead of reading 180°. + #[test] + fn shift_drag_keeps_its_step_when_an_anchor_lies_behind_the_start() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + cx.snap_tolerance = 4.0; + cx.snapping_tolerance = 4.0; + assert!(cx.snap_to_point); + let shift = Mods { shift: true, ..Mods::default() }; + let mut t = MeasureTool::default(); + // From (200.3, 196.005), the pointer at (202.3, 196.805) points 0° and snaps to the anchor + // at (200, 200), which projects onto the 0° line at (200, 196.005), behind the start. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.3, 196.005)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.3, 196.805).with_mods(shift)); + let o = t.options(); + assert!(o["angle"].as_f64().unwrap().abs() < 1e-9, "{o}"); + assert!((o["dx"].as_f64().unwrap() - 0.3).abs() < 1e-9, "{o}"); + assert!(o["dy"].as_f64().unwrap().abs() < 1e-9, "{o}"); + } + /// With Snap to Point alone, a Shift-drag's end slides along its 45° step to a ruler guide. #[test] fn shift_drag_meets_a_ruler_guide() { diff --git a/crates/trace/src/layers.rs b/crates/trace/src/layers.rs index 3c9467d766..d9b34cc549 100644 --- a/crates/trace/src/layers.rs +++ b/crates/trace/src/layers.rs @@ -331,7 +331,9 @@ impl Layers<'_> { } } } - for comp in comps { + // One component's paths: a pure function of its loops and the shared read-only state, so components are fitted in + // parallel and then added in order. + let fit_comp = |comp: &contour::Component| -> Option<(PathData, Option)> { // (`hard`: some of the outline has no anti-aliasing, and is known less exactly.) let hard = std::cell::Cell::new(false); // (Each loop fitted, and whether its own outline is hard.) @@ -355,7 +357,7 @@ impl Layers<'_> { hard.set(hard.get() || is_hard); Some((sp, is_hard)) }; - let Some((outer, _)) = fit(&comp.outer) else { continue }; + let (outer, _) = fit(&comp.outer)?; let mut subs = vec![outer]; for hole in &comp.holes { if hole.area2.abs() / 2 < min_hole && params.noise > 0 { @@ -398,9 +400,16 @@ impl Layers<'_> { _ => None, }; let path = shape.map_or_else(|| PathData::new(subs), |s| s.path()); - if !add(path, comp.pixels, area_stroke, shape, false) { - return None; - } + Some((path, shape)) + }; + // In chunks, so that the anchor budget still stops the work early: a hostile image is refused after fitting at most + // the rest of one chunk, not after every component is fitted. + let accepted = fit_in_chunks(&comps, FIT_CHUNK, fit_comp, |comp, fitted| { + let Some((path, shape)) = fitted else { return true }; + add(path, comp.pixels, area_stroke, shape, false) + }); + if !accepted { + return None; } for line in lines { if !add(line.path, line.pixels, Some(line.width), None, line.square) { @@ -452,6 +461,24 @@ pub(crate) fn shift_probe(img: &Raster, params: &TraceParams) -> Vec<[f64; 4]> { out } +/// How many components of a layer are fitted at a time (see `fit_in_chunks`). +const FIT_CHUNK: usize = 256; + +/// `fit` of each of `items` over the available cores, `chunk` items at a time, each result handed to `accept` in item +/// order. Stops after the chunk in which `accept` returns false, and says whether it never did. +fn fit_in_chunks(items: &[T], chunk: usize, fit: impl Fn(&T) -> R + Sync, mut accept: impl FnMut(&T, R) -> bool) -> bool { + for part in items.chunks(chunk.max(1)) { + // (A few components are not worth starting threads for.) + let fitted = if part.len() < 16 { part.iter().map(&fit).collect() } else { parallel_map(part, &fit) }; + for (item, result) in part.iter().zip(fitted) { + if !accept(item, result) { + return false; + } + } + } + true +} + /// `f` of each of `items`, in order, spread over the available cores. pub(crate) fn parallel_map(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { parallel_map_at_most(usize::MAX, items, f) @@ -492,3 +519,50 @@ pub(crate) fn parallel_map_at_most(at_a_time: usize, items: &[ pub(crate) fn parallel_map_at_most(_: usize, items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec { items.iter().map(f).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fit_in_chunks_keeps_the_order_and_stops_at_the_first_refusal() { + let items: Vec = (0..1000).collect(); + let fitted = AtomicUsize::new(0); + let mut seen = vec![]; + let all = fit_in_chunks( + &items, + 16, + |&i| { + fitted.fetch_add(1, Ordering::Relaxed); + i * 2 + }, + |&i, r| { + assert_eq!(r, i * 2); + seen.push(i); + seen.len() <= 10 + }, + ); + assert!(!all); + // Items come in order, up to and including the one refused. + assert_eq!(seen, (0..=10).collect::>()); + // The early stop is kept: only the chunk holding the refusal was fitted, not the other 984 items. + assert_eq!(fitted.load(Ordering::Relaxed), 16); + } + + #[test] + fn fit_in_chunks_accepts_everything_in_order() { + let items: Vec = (0..1000).collect(); + let mut out = vec![]; + assert!(fit_in_chunks( + &items, + 7, + |&i| i + 1, + |_, r| { + out.push(r); + true + } + )); + assert_eq!(out, (1..=1000).collect::>()); + assert!(fit_in_chunks(&[] as &[usize], 7, |&i| i, |_, _| false)); + } +} diff --git a/crates/trace/src/logo/geom.rs b/crates/trace/src/logo/geom.rs index ca77f27612..b33b358856 100644 --- a/crates/trace/src/logo/geom.rs +++ b/crates/trace/src/logo/geom.rs @@ -2,6 +2,7 @@ use kurbo::Vec2; use std::collections::HashMap; +use std::hash::{BuildHasherDefault, Hasher}; use vectorcraft_geom::Point; /// One cubic Bézier segment: start, two handles, end. @@ -315,13 +316,39 @@ pub(crate) fn sample_cubics(cs: &[Cubic], spacing: f64) -> Vec { out } +/// Multiply-and-rotate hasher for the small integer cell keys of [`all_within`]. The grid is only ever looked up, never +/// iterated, so the hash cannot change a result; std's SipHash took about a quarter of the time of a noisy trace. +#[derive(Default)] +struct CellHasher(u64); + +impl CellHasher { + fn add(&mut self, word: u64) { + self.0 = (self.0.rotate_left(5) ^ word).wrapping_mul(0x517c_c1b7_2722_0a95); + } +} + +impl Hasher for CellHasher { + fn write(&mut self, bytes: &[u8]) { + bytes.iter().for_each(|&b| self.add(u64::from(b))); + } + + fn write_i64(&mut self, word: i64) { + self.add(word as u64); + } + + fn finish(&self) -> u64 { + // The table picks its bucket from the low bits, which a product leaves weak: bring the strong high bits down. + self.0.rotate_left(26) + } +} + /// True when every point of `a` has a point of `b` within `lim`. pub(crate) fn all_within(a: &[Point], b: &[Point], lim: f64) -> bool { if lim.is_nan() || lim <= 0.0 { return false; } let key = |p: Point| ((p.x / lim).floor() as i64, (p.y / lim).floor() as i64); - let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut grid: HashMap<(i64, i64), Vec, BuildHasherDefault> = HashMap::default(); for &q in b { grid.entry(key(q)).or_default().push(q); } @@ -426,4 +453,40 @@ mod tests { assert!(!all_within(&a, &b, 0.1)); assert!(!all_within(&a, &b, 0.0)); } + + #[test] + fn within_agrees_with_a_brute_force_search() { + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + let mut rand = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state >> 11) as f64 / (1u64 << 53) as f64 + }; + for round in 0..60 { + // Negative and large coordinates, clustered and sparse clouds. + let spread = [3.0, 40.0, 1.0e5][round % 3]; + let cloud = |n: usize, rand: &mut dyn FnMut() -> f64| -> Vec { + (0..n).map(|_| Point::new((rand() - 0.5) * spread, (rand() - 0.5) * spread)).collect() + }; + let a = cloud(1 + round % 7, &mut rand); + let mut b = cloud(200, &mut rand); + b.extend(a.iter().map(|p| Point::new(p.x + (rand() - 0.5) * 0.3, p.y + (rand() - 0.5) * 0.3))); + for lim in [0.05, 0.2, 1.5, 30.0] { + let brute = a.iter().all(|&p| b.iter().any(|q| q.distance(p) <= lim)); + assert_eq!(all_within(&a, &b, lim), brute, "round {round}, lim {lim}"); + assert_eq!(all_within(&b, &a, lim), b.iter().all(|&p| a.iter().any(|q| q.distance(p) <= lim)), "round {round}, lim {lim}"); + } + } + } + + #[test] + fn cell_hasher_spreads_keys_whose_low_bits_are_zero() { + use std::hash::BuildHasher; + // Cell indices that are all multiples of 1024 (pixel-exact points with a power-of-two limit). + let build = BuildHasherDefault::::default(); + let buckets: std::collections::HashSet = + (0..64i64).flat_map(|i| (0..64i64).map(move |j| (i << 10, j << 10))).map(|k| build.hash_one(k) & 1023).collect(); + assert!(buckets.len() > 600, "{} of 1024 buckets used", buckets.len()); + } } diff --git a/crates/trace/src/quantize/denoise.rs b/crates/trace/src/quantize/denoise.rs index cb40ffa4fa..a929691466 100644 --- a/crates/trace/src/quantize/denoise.rs +++ b/crates/trace/src/quantize/denoise.rs @@ -13,94 +13,286 @@ pub fn denoise(labels: &mut [u16], w: usize, h: usize, min_area: usize) { /// clears specks, not a colour's only regions. Smaller regions of it go as any other. pub(crate) fn denoise_keeping(labels: &mut [u16], w: usize, h: usize, min_area: usize, keep: &[bool]) { use std::cmp::Reverse; - use std::collections::{BTreeMap, BinaryHeap}; + use std::collections::BinaryHeap; - if min_area <= 1 || w == 0 || h == 0 || w.checked_mul(h) != Some(labels.len()) { + // Every index below is a `u32`, so a pixel costs 8 bytes and a region 18 instead of a `Vec` per region. An image + // of `u32::MAX` pixels or more (far above the decoder's cap) is left as it is. + const NONE: u32 = u32::MAX; + if min_area <= 1 || w == 0 || h == 0 || w.checked_mul(h) != Some(labels.len()) || labels.len() >= NONE as usize { return; } - struct Region { - label: u16, - pixels: Vec, - } - // Region membership is kept current after every merge. In particular, no stale `seen` - // flags can exclude previously recoloured pixels from a subsequent connected component. - let mut at = vec![usize::MAX; labels.len()]; - let mut regions: Vec = Vec::new(); + // Region of each pixel as first found (`NONE` for transparent). A merge does not touch it: the region a pixel + // belongs to now is `find(at[i])`. + let mut at = vec![NONE; labels.len()]; + // Pixels of a region form a singly linked list through `next`, so absorbing a region is one pointer write. + let mut next = vec![NONE; labels.len()]; + let mut head: Vec = Vec::new(); + let mut tail: Vec = Vec::new(); + let mut size: Vec = Vec::new(); + let mut colour: Vec = Vec::new(); + // Union-find over region ids: a region absorbed into another points at it. + let mut parent: Vec = Vec::new(); let neighbours = |i: usize| { let (x, y) = (i % w, i / w); [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)] }; for start in 0..labels.len() { - if at[start] != usize::MAX || labels[start] == TRANSPARENT { + if at[start] != NONE || labels[start] == TRANSPARENT { continue; } - let id = regions.len(); + let id = head.len() as u32; let label = labels[start]; - let mut pixels = vec![start]; at[start] = id; - let mut cursor = 0; - while let Some(&i) = pixels.get(cursor) { - for j in neighbours(i).into_iter().flatten() { - if at[j] == usize::MAX && labels[j] == label { + let mut last = start; + let mut count = 1u32; + let mut cursor = start; + loop { + for j in neighbours(cursor).into_iter().flatten() { + if at[j] == NONE && labels[j] == label { at[j] = id; - pixels.push(j); + next[last] = j as u32; + last = j; + count += 1; } } - cursor += 1; + match next[cursor] { + NONE => break, + n => cursor = n as usize, + } } - regions.push(Region { label, pixels }); + head.push(start as u32); + tail.push(last as u32); + size.push(count); + colour.push(label); + parent.push(id); } - let mut pending: BinaryHeap<_> = regions.iter().enumerate().map(|(id, r)| Reverse((r.pixels.len(), id))).collect(); - while let Some(Reverse((area, id))) = pending.pop() { - if area >= min_area { - break; + fn find(parent: &mut [u32], mut a: u32) -> u32 { + while parent[a as usize] != a { + let up = parent[parent[a as usize] as usize]; + parent[a as usize] = up; + a = up; } - if regions[id].pixels.len() != area { + a + } + // Only regions below `min_area` are ever taken from the queue; a larger one ends the loop. + let mut pending: BinaryHeap<_> = + (0..size.len() as u32).filter(|&id| (size[id as usize] as usize) < min_area).map(|id| Reverse((size[id as usize], id))).collect(); + // Scratch for one merge: `(other region, shared pixel edges)` and `(colour, shared pixel edges)`. + let mut borders: Vec<(u32, usize)> = Vec::new(); + let mut by_colour: Vec<(u16, usize)> = Vec::new(); + while let Some(Reverse((area, id))) = pending.pop() { + if size[id as usize] != area { continue; // absorbed region or an old heap entry for a region that grew } - if area >= RARE_ISLAND && keep.get(regions[id].label as usize).copied().unwrap_or(false) { + if area as usize >= RARE_ISLAND && keep.get(colour[id as usize] as usize).copied().unwrap_or(false) { continue; } - let mut borders: BTreeMap = BTreeMap::new(); - for &i in ®ions[id].pixels { - for j in neighbours(i).into_iter().flatten() { - let other = at[j]; - if other != id && other != usize::MAX { - *borders.entry(other).or_default() += 1; + borders.clear(); + // Pairs are folded together whenever the list doubles, so a big region keeps it short. + let mut limit = 4096; + let mut pixel = head[id as usize]; + while pixel != NONE { + for j in neighbours(pixel as usize).into_iter().flatten() { + if at[j] != NONE { + let other = find(&mut parent, at[j]); + if other != id { + borders.push((other, 1)); + } } } + pixel = next[pixel as usize]; + if borders.len() >= limit { + merge_counts(&mut borders); + limit = limit.max(2 * borders.len()); + } } - let mut colours: BTreeMap = BTreeMap::new(); - for (&other, &border) in &borders { - *colours.entry(regions[other].label).or_default() += border; - } - let Some((&colour, _)) = colours.iter().max_by_key(|(label, border)| (**border, Reverse(**label))) else { + merge_counts(&mut borders); + by_colour.clear(); + by_colour.extend(borders.iter().map(|&(other, border)| (colour[other as usize], border))); + merge_counts(&mut by_colour); + let Some(&(chosen, _)) = by_colour.iter().max_by_key(|(label, border)| (*border, Reverse(*label))) else { continue; // an opaque island surrounded only by transparency stays intact }; // All adjacent regions of the chosen colour become connected by this merge. Absorb - // them together, keeping the largest one's storage to avoid repeatedly moving it. + // them together, keeping the largest one as the survivor. let Some(target) = borders - .keys() - .copied() - .filter(|&other| regions[other].label == colour) - .max_by_key(|&other| (regions[other].pixels.len(), Reverse(other))) + .iter() + .map(|&(other, _)| other) + .filter(|&other| colour[other as usize] == chosen) + .max_by_key(|&other| (size[other as usize], Reverse(other))) else { continue; }; - let sources: Vec<_> = - std::iter::once(id).chain(borders.keys().copied().filter(|&other| other != target && regions[other].label == colour)).collect(); - for source in sources { - let pixels = std::mem::take(&mut regions[source].pixels); - for &i in &pixels { - at[i] = target; - } - regions[target].pixels.extend(pixels); + for source in + std::iter::once(id).chain(borders.iter().map(|&(other, _)| other).filter(|&other| other != target && colour[other as usize] == chosen)) + { + next[tail[target as usize] as usize] = head[source as usize]; + tail[target as usize] = tail[source as usize]; + size[target as usize] += size[source as usize]; + size[source as usize] = 0; + parent[source as usize] = target; + } + if (size[target as usize] as usize) < min_area { + pending.push(Reverse((size[target as usize], target))); } - pending.push(Reverse((regions[target].pixels.len(), target))); } - for region in regions { - for i in region.pixels { - labels[i] = region.label; + for (i, ®ion) in at.iter().enumerate() { + if region != NONE { + labels[i] = colour[find(&mut parent, region) as usize]; } } } + +/// Sort `(key, count)` pairs and add up the counts of equal keys. +fn merge_counts(pairs: &mut Vec<(K, usize)>) { + pairs.sort_unstable_by_key(|p| p.0); + pairs.dedup_by(|b, a| { + let same = a.0 == b.0; + if same { + a.1 += b.1; + } + same + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The first implementation (a pixel `Vec` per region, `BTreeMap` borders), kept to check that the compact one merges + /// in exactly the same order. + fn denoise_reference(labels: &mut [u16], w: usize, h: usize, min_area: usize, keep: &[bool]) { + use std::cmp::Reverse; + use std::collections::{BTreeMap, BinaryHeap}; + + if min_area <= 1 || w == 0 || h == 0 || w.checked_mul(h) != Some(labels.len()) { + return; + } + struct Region { + label: u16, + pixels: Vec, + } + // Region membership is kept current after every merge. In particular, no stale `seen` + // flags can exclude previously recoloured pixels from a subsequent connected component. + let mut at = vec![usize::MAX; labels.len()]; + let mut regions: Vec = Vec::new(); + let neighbours = |i: usize| { + let (x, y) = (i % w, i / w); + [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)] + }; + for start in 0..labels.len() { + if at[start] != usize::MAX || labels[start] == TRANSPARENT { + continue; + } + let id = regions.len(); + let label = labels[start]; + let mut pixels = vec![start]; + at[start] = id; + let mut cursor = 0; + while let Some(&i) = pixels.get(cursor) { + for j in neighbours(i).into_iter().flatten() { + if at[j] == usize::MAX && labels[j] == label { + at[j] = id; + pixels.push(j); + } + } + cursor += 1; + } + regions.push(Region { label, pixels }); + } + let mut pending: BinaryHeap<_> = regions.iter().enumerate().map(|(id, r)| Reverse((r.pixels.len(), id))).collect(); + while let Some(Reverse((area, id))) = pending.pop() { + if area >= min_area { + break; + } + if regions[id].pixels.len() != area { + continue; // absorbed region or an old heap entry for a region that grew + } + if area >= RARE_ISLAND && keep.get(regions[id].label as usize).copied().unwrap_or(false) { + continue; + } + let mut borders: BTreeMap = BTreeMap::new(); + for &i in ®ions[id].pixels { + for j in neighbours(i).into_iter().flatten() { + let other = at[j]; + if other != id && other != usize::MAX { + *borders.entry(other).or_default() += 1; + } + } + } + let mut colours: BTreeMap = BTreeMap::new(); + for (&other, &border) in &borders { + *colours.entry(regions[other].label).or_default() += border; + } + let Some((&colour, _)) = colours.iter().max_by_key(|(label, border)| (**border, Reverse(**label))) else { + continue; // an opaque island surrounded only by transparency stays intact + }; + // All adjacent regions of the chosen colour become connected by this merge. Absorb + // them together, keeping the largest one's storage to avoid repeatedly moving it. + let Some(target) = borders + .keys() + .copied() + .filter(|&other| regions[other].label == colour) + .max_by_key(|&other| (regions[other].pixels.len(), Reverse(other))) + else { + continue; + }; + let sources: Vec<_> = + std::iter::once(id).chain(borders.keys().copied().filter(|&other| other != target && regions[other].label == colour)).collect(); + for source in sources { + let pixels = std::mem::take(&mut regions[source].pixels); + for &i in &pixels { + at[i] = target; + } + regions[target].pixels.extend(pixels); + } + pending.push(Reverse((regions[target].pixels.len(), target))); + } + for region in regions { + for i in region.pixels { + labels[i] = region.label; + } + } + } + + fn noisy(w: usize, h: usize, colours: u64, transparent_one_in: u64, seed: u64) -> Vec { + let mut state = seed; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + (0..w * h).map(|_| if transparent_one_in > 0 && next() % transparent_one_in == 0 { TRANSPARENT } else { (next() % colours) as u16 }).collect() + } + + #[test] + fn compact_denoise_matches_the_reference_merge_order() { + for (seed, (w, h, colours, transparent)) in + [(31, 17, 3, 0), (64, 64, 2, 0), (50, 70, 5, 9), (97, 41, 8, 0), (120, 90, 4, 5), (7, 300, 3, 0), (1, 40, 3, 0), (40, 1, 3, 0)] + .into_iter() + .enumerate() + { + let base = noisy(w, h, colours, transparent, seed as u64 + 1); + for min_area in [2, 3, 5, 9, 25, 80, 400, 100_000] { + for keep in [&[][..], &[false, true, false, true][..]] { + let (mut a, mut b) = (base.clone(), base.clone()); + denoise_reference(&mut a, w, h, min_area, keep); + denoise_keeping(&mut b, w, h, min_area, keep); + assert_eq!(a, b, "{w}x{h}, {colours} colours, min_area {min_area}, keep {keep:?}"); + } + } + } + } + + /// A minimum so large that regions keep merging into big ones. + #[test] + fn compact_denoise_matches_the_reference_with_a_large_min_area() { + let (w, h) = (320, 320); + let base = noisy(w, h, 6, 0, 99); + let (mut a, mut b) = (base.clone(), base); + denoise_reference(&mut a, w, h, 50_000, &[]); + denoise_keeping(&mut b, w, h, 50_000, &[]); + assert_eq!(a, b); + } +} diff --git a/crates/trace/src/smooth.rs b/crates/trace/src/smooth.rs index 7cd8d18568..c449f4f9ae 100644 --- a/crates/trace/src/smooth.rs +++ b/crates/trace/src/smooth.rs @@ -13,33 +13,54 @@ use crate::{Raster, parallel_map}; /// Colours this far apart (levels, all channels together) count half as much as equal ones. const ALIKE: f64 = 20.0; -/// One pass along rows (`dx = 1`) or columns (`dx = 0`). -fn pass(src: &[u8], w: usize, h: usize, along_rows: bool) -> Vec { +/// How much a neighbour counts, by the squared distance between the two colours (all channels +/// together): `1 / (1 + d2 / ALIKE²)²`. Distances are whole numbers, so every value a pass can ask +/// for is worked out once here instead of once per pixel and neighbour. +fn weight_table() -> Vec { + (0..=3 * 255 * 255usize).map(|d2| 1.0 / (1.0 + d2 as f64 / (ALIKE * ALIKE)).powi(2)).collect() +} + +/// `v` rounded to the nearest level, halves up. The same as `v.round() as u8` for `0 <= v <= 255`, +/// without the library call that `round` is on targets that lack a rounding instruction. +fn level(v: f64) -> u8 { + let whole = v as u8; + whole.saturating_add(u8::from(v - f64::from(whole) >= 0.5)) +} + +/// The pixel at column `x` of `row`. +fn at(row: &[u8], x: usize) -> Option<&[u8]> { + row.get(x * 4..x * 4 + 4) +} + +/// One pass along rows (`dx = 1`) or columns (`dx = 0`), weighting by `weights` (see `weight_table`). +fn pass(src: &[u8], w: usize, h: usize, along_rows: bool, weights: &[f64]) -> Vec { let rows: Vec = (0..h).collect(); - let px = |x: usize, y: usize| src.get((y * w + x) * 4..(y * w + x) * 4 + 4); + let row = |y: usize| src.get(y * w * 4..(y + 1) * w * 4).unwrap_or_default(); let done = parallel_map(&rows, |&y| { + let cur = row(y); + let (above, below) = (y.checked_sub(1).map(row), (y + 1 < h).then(|| row(y + 1))); let mut out = Vec::with_capacity(w * 4); - for x in 0..w { - let Some(p) = px(x, y) else { continue }; + for (x, p) in cur.as_chunks::<4>().0.iter().enumerate() { if p[3] < 128 { out.extend_from_slice(p); continue; } let near = if along_rows { - [(x > 0).then(|| (x - 1, y)), (x + 1 < w).then(|| (x + 1, y))] + [x.checked_sub(1).and_then(|x| at(cur, x)), at(cur, x + 1)] } else { - [(y > 0).then(|| (x, y - 1)), (y + 1 < h).then(|| (x, y + 1))] + [above.and_then(|r| at(r, x)), below.and_then(|r| at(r, x))] }; let (mut sum, mut weight) = ([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])], 1.0); - for q in near.into_iter().flatten().filter_map(|(x, y)| px(x, y)).filter(|q| q[3] >= 128) { - let d2: f64 = (0..3).map(|k| (f64::from(q[k]) - f64::from(p[k])).powi(2)).sum(); - let wt = 1.0 / (1.0 + d2 / (ALIKE * ALIKE)).powi(2); + for q in near.into_iter().flatten().filter(|q| q[3] >= 128) { + let d = |k: usize| i32::from(q[k]) - i32::from(p[k]); + let d2 = (d(0) * d(0) + d(1) * d(1) + d(2) * d(2)) as usize; + let wt = weights.get(d2).copied().unwrap_or(0.0); for k in 0..3 { sum[k] += wt * f64::from(q[k]); } weight += wt; } - out.extend(sum.map(|v| (v / weight).round() as u8)); + out.extend(sum.map(|v| level(v / weight))); out.push(p[3]); } out @@ -52,8 +73,9 @@ pub(crate) fn smoothed(img: &Raster) -> Raster { let (w, h) = (img.width as usize, img.height as usize); let mut rgba = img.rgba.clone(); if rgba.len() == w * h * 4 { + let weights = weight_table(); for _ in 0..3 { - rgba = pass(&pass(&rgba, w, h, true), w, h, false); + rgba = pass(&pass(&rgba, w, h, true, &weights), w, h, false, &weights); } } Raster { width: img.width, height: img.height, rgba } @@ -85,6 +107,123 @@ mod tests { assert!(out.pixel(19, 10)[0] < 70 && out.pixel(20, 10)[0] > 150); } + /// The filter as it was before the weight table: the reference the fast pass must match byte for byte. + fn reference(src: &[u8], w: usize, h: usize, along_rows: bool) -> Vec { + let px = |x: usize, y: usize| src.get((y * w + x) * 4..(y * w + x) * 4 + 4); + let mut out = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let Some(p) = px(x, y) else { continue }; + if p[3] < 128 { + out.extend_from_slice(p); + continue; + } + let near = if along_rows { + [(x > 0).then(|| (x - 1, y)), (x + 1 < w).then(|| (x + 1, y))] + } else { + [(y > 0).then(|| (x, y - 1)), (y + 1 < h).then(|| (x, y + 1))] + }; + let (mut sum, mut weight) = ([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])], 1.0); + for q in near.into_iter().flatten().filter_map(|(x, y)| px(x, y)).filter(|q| q[3] >= 128) { + let d2: f64 = (0..3).map(|k| (f64::from(q[k]) - f64::from(p[k])).powi(2)).sum(); + let wt = 1.0 / (1.0 + d2 / (ALIKE * ALIKE)).powi(2); + for k in 0..3 { + sum[k] += wt * f64::from(q[k]); + } + weight += wt; + } + out.extend(sum.map(|v| (v / weight).round() as u8)); + out.push(p[3]); + } + } + out + } + + fn reference_smoothed(img: &Raster) -> Vec { + let (w, h) = (img.width as usize, img.height as usize); + let mut rgba = img.rgba.clone(); + for _ in 0..3 { + rgba = reference(&reference(&rgba, w, h, true), w, h, false); + } + rgba + } + + fn hash(x: u32, y: u32, salt: u32) -> u32 { + let mut n = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ salt.wrapping_mul(0xC2B2_AE3D); + n ^= n >> 15; + n = n.wrapping_mul(0x2C1B_3C6D); + n ^= n >> 13; + n.wrapping_mul(0x297A_2D39) ^ (n >> 16) + } + + #[test] + fn same_bytes_as_the_unoptimised_filter() { + let inputs: Vec<(&str, Raster)> = vec![ + ("random colours", Raster::from_fn(61, 47, |x, y| [hash(x, y, 1) as u8, (hash(x, y, 2) >> 3) as u8, (hash(x, y, 3) >> 5) as u8, 255])), + ( + "photo-like", + Raster::from_fn(90, 70, |x, y| { + let base = ((x as f64 / 9.0).sin() * 60.0 + (y as f64 / 13.0).cos() * 60.0 + 128.0) as i32; + let v = (base + (hash(x, y, 4) % 9) as i32 - 4).clamp(0, 255) as u8; + [v, (i32::from(v) / 2 + 60) as u8, 255 - v, 255] + }), + ), + ("gradient", Raster::from_fn(80, 33, |x, y| [(x * 3) as u8, (y * 7) as u8, (x + y) as u8, 255])), + ("flat", Raster::from_fn(20, 20, |_, _| [40, 90, 200, 255])), + ("extremes", Raster::from_fn(32, 32, |x, y| if hash(x, y, 5).is_multiple_of(2) { [0, 0, 0, 255] } else { [255, 255, 255, 255] })), + ( + "holes and half alpha", + Raster::from_fn(50, 40, |x, y| { + [hash(x, y, 6) as u8, (hash(x, y, 7) >> 8) as u8, 77, [0, 127, 128, 255, 255][(hash(x, y, 8) % 5) as usize]] + }), + ), + ("one column", Raster::from_fn(1, 30, |_, y| [(y * 8) as u8, 5, 9, 255])), + ("one row", Raster::from_fn(30, 1, |x, _| [(x * 8) as u8, 5, 9, 255])), + ("one pixel", Raster::from_fn(1, 1, |_, _| [1, 2, 3, 255])), + ("two by two", Raster::from_fn(2, 2, |x, y| [(x * 100) as u8, (y * 100) as u8, 3, 255])), + ("wide", Raster::from_fn(257, 3, |x, y| [x as u8, (x / 2) as u8, y as u8, 255])), + ]; + for (name, img) in &inputs { + assert_eq!(smoothed(img).rgba, reference_smoothed(img), "{name}"); + } + } + + #[test] + fn same_bytes_for_every_pair_of_grey_levels() { + // Every pixel next to every possible neighbour, one table for all of them (the picture + // tests above run the three rounds). + let weights = weight_table(); + for p in 0..=255u8 { + for q in 0..=255u8 { + // Grey, and a mixed pair, along a row (2x1) and along a column (1x2). + for src in [[p, p, p, 255, q, q, q, 255], [p, q, p ^ q, 255, q, p, q ^ 0x5a, 255]] { + assert_eq!(pass(&src, 2, 1, true, &weights), reference(&src, 2, 1, true), "{p} {q}"); + assert_eq!(pass(&src, 1, 2, false, &weights), reference(&src, 1, 2, false), "{p} {q}"); + } + } + } + } + + #[test] + fn level_is_round_for_every_value_that_can_occur() { + for v in + [0.0, 0.499_999_999_999_999_94, 0.5, 0.500_000_000_000_000_1, 1.499_999_999_999_999_8, 1.5, 127.5, 254.5, 254.999_999_999_999_97, 255.0] + { + assert_eq!(level(v), v.round() as u8, "{v}"); + } + // Values a hair either side of every half, where a wrong rounding would show. + for k in 0..255 { + let half = f64::from(k) + 0.5; + for v in [half, f64::from_bits(half.to_bits() - 1), f64::from_bits(half.to_bits() + 1)] { + assert_eq!(level(v), v.round() as u8, "{v}"); + } + } + for i in 0..=255 * 4096u32 { + let v = f64::from(i) / 4096.0; + assert_eq!(level(v), v.round() as u8, "{v}"); + } + } + #[test] fn transparent_pixels_and_odd_sizes_pass_through() { let img = Raster::from_fn(3, 1, |x, _| [10 * x as u8, 0, 0, if x == 1 { 0 } else { 255 }]); diff --git a/crates/trace/src/tests.rs b/crates/trace/src/tests.rs index bcc2242b32..6b3298a7b4 100644 --- a/crates/trace/src/tests.rs +++ b/crates/trace/src/tests.rs @@ -609,6 +609,32 @@ fn a_trace_past_its_anchor_budget_is_refused() { assert_eq!(trace_within(&img, &p, anchors).unwrap(), full); } +/// Components of a layer are fitted in parallel, a chunk at a time, and added in the order they were found (top to +/// bottom, left to right), also across the chunks. The anchor budget still refuses a trace with many components. +#[test] +fn many_components_come_out_in_scan_order_and_still_meet_the_budget() { + // 20 x 20 black dots, more than one chunk of components. + let img = Raster::from_fn(120, 120, |x, y| if x % 6 < 3 && y % 6 < 3 { BLACK } else { WHITE }); + let p = TraceParams { noise: 1, ..bw() }; + let full = trace(&img, &p); + assert_eq!(full.paths.len(), 400); + let tops: Vec<(i64, i64)> = full + .paths + .iter() + .map(|t| { + let b = t.path.bounds().unwrap(); + (b.y0.round() as i64, b.x0.round() as i64) + }) + .collect(); + assert!(tops.windows(2).all(|w| w[0] < w[1]), "not in scan order: {tops:?}"); + assert_eq!(trace(&img, &p), full, "tracing twice differs"); + let anchors = full.anchor_count(); + assert!(matches!(trace_within(&img, &p, anchors / 2), Err(TraceError::TooComplex { max }) if max == anchors / 2)); + // The last component alone is over the budget: a refusal in a later chunk. + assert!(matches!(trace_within(&img, &p, anchors - 1), Err(TraceError::TooComplex { .. }))); + assert_eq!(trace_within(&img, &p, anchors).unwrap(), full); +} + /// #3098: a watched trace says how far it has got, layer by layer, and stops when told to. #[test] fn a_watched_trace_counts_its_layers_and_stops_when_told() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7cbe32d1eb..fbcf1dd0c2 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -444,10 +444,10 @@ impl VectorcraftApp { if !canvas::tiled_area(self, ui) { if floats { let rect = ui.available_rect_before_wrap(); - if let Some(i) = crate::doc_windows::tab_document(self) - && canvas::pane(self, ui, i, rect) + if let Some(w) = crate::doc_windows::tab_window(self) + && canvas::pane(self, ui, w, rect) { - chrome::show_document(self, i); + crate::doc_windows::show_window(self, w); } } else { canvas::show(self, ui); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 08e7960b3e..775248a611 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -188,6 +188,12 @@ impl VectorcraftApp { r.insert(0, font.to_string()); r.truncate(MAX_RECENT_FONTS); } + if id == "text.replaceFont" + && v.get("warning").is_none() + && self.ui.status.starts_with("some text is hidden or locked, so its font wasn't replaced") + { + self.ui.status.clear(); + } // A command's warning (Rasterize's when an image couldn't be decoded) shows in the // status bar, as a tool preview's does. if let Some(w) = v.get("warning").and_then(Value::as_str) { diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 0100ed100b..6b5db4fb5b 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -2,7 +2,8 @@ use super::{Value, VectorcraftApp, View, ViewInfo, panels}; impl VectorcraftApp { /// Keep `views` aligned with the session's documents (a new one starts at its saved view, and - /// opens in a floating window while Open Documents As Tabs is cleared). + /// opens in a floating window while Open Documents As Tabs is cleared). The views Window › New + /// Window opened go with their documents. pub fn sync_views(&mut self) { let docs = self.session.documents(); self.views.truncate(docs.len()); @@ -10,6 +11,7 @@ impl VectorcraftApp { for (view, doc) in self.views.iter_mut().zip(docs) { view.artboard = view.artboard.min(doc.doc.artboards.len().saturating_sub(1)); } + crate::doc_windows::retain_views(self); crate::doc_windows::opened(self); // Undo can remove the Artboard tool's active or additionally selected boards. Repair only // invalid indices: valid multiselection, drag state and tool options must survive history. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1330cae2b9..6e4bfa38ee 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -253,7 +253,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let doc = st.doc.clone(); let mask_view = st.shown_mask(); - paint_paper(app, st, ui, &painter, &xf, rect, &t); + paint_paper(app, st, ui, &painter, &xf, rect, (&t, app.outline_view())); // The grid behind the art (Guides & Grid › Grids In Back), else over it below. let grid_look = (app.ui.view.grid && !presenting).then(|| LineLook::grid(&app.session.prefs)); let grids_in_back = app.session.prefs.grids_in_back; @@ -574,7 +574,7 @@ fn frame_options( mask_view: Option, smooth_images: bool, ) -> vectorcraft_render::RenderOptions { - let opts = vectorcraft_render::RenderOptions { outline: app.outline_view(), background: None, artboards: false, ..Default::default() }; + let opts = vectorcraft_render::RenderOptions { outline: key.outline, background: None, artboards: false, ..Default::default() }; vectorcraft_render::RenderOptions { proof: vectorcraft_render::proof::active_proof(), overprint_preview: vectorcraft_render::proof::overprint_preview_on(), @@ -678,8 +678,16 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { } /// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards, of -/// document `st`. -fn paint_paper(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, ui: &Ui, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) { +/// document `st`, in Outline when `outline`. +fn paint_paper( + app: &VectorcraftApp, + st: &vectorcraft_engine::DocState, + ui: &Ui, + painter: &egui::Painter, + xf: &Xf, + rect: egui::Rect, + (t, outline): (&Tokens, bool), +) { let doc = &st.doc; // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the // simulated paper colour; a white Background Contents hides the grid). @@ -691,7 +699,7 @@ fn paint_paper(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, ui: &Ui, // View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038). let setup = &doc.setup; painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }); - } else if !app.outline_view() { + } else if !outline { let setup = &doc.setup; let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup)); let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE }; @@ -709,7 +717,7 @@ fn paint_paper(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, ui: &Ui, } } } - } else if app.outline_view() { + } else { for ab in &doc.artboards { painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE)); } diff --git a/crates/ui-egui/src/canvas/panes.rs b/crates/ui-egui/src/canvas/panes.rs index 5876537c3b..5bd974bf92 100644 --- a/crates/ui-egui/src/canvas/panes.rs +++ b/crates/ui-egui/src/canvas/panes.rs @@ -1,7 +1,8 @@ //! Window › Arrange › Tile: every open document in a pane of its own, edge to edge in the document //! area. The current document's pane is the canvas ([`super::show`]). Each other pane draws its //! document from a render of its own, with its artboards, guides and selected objects, and a press -//! in it makes its document the current one. +//! in it makes its document the current one. Each view Window › New Window opened has a pane of +//! its own ([`crate::doc_windows`]). use std::collections::HashMap; use std::sync::Arc; @@ -13,28 +14,32 @@ use super::{ Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects, }; +use crate::doc_windows::Win; use crate::thumb_worker::ThumbWorker; /// A pane's render: what it shows, the document, its size in pixels and view, and its options. type PaneFrame = (CacheKey, Arc, ((u32, u32), Affine), vectorcraft_render::RenderOptions); -/// The renders that the panes of the documents other than the current one draw. +/// Whose render a pane draws: its document's uid and its view's number. +type Slot = (u64, u32); + +/// The renders that the panes of the windows other than the current one draw. #[derive(Default)] pub struct Panes { /// Renders off the UI thread (None: on it, as on wasm). - worker: Option>, + worker: Option>, worker_started: bool, /// Renders on the UI thread when there is no worker. renderer: Option, - /// By document uid: the render shown and what it shows. - shown: HashMap, - /// By document uid: the render the worker is making. - pending: HashMap, - /// The panes the last frame drew, as (document index, pane rect). - pub(crate) last: Vec<(usize, egui::Rect)>, - /// By document uid: the size of the view's area its document was last fitted to in a pane, - /// and the zoom and center the fit gave. - fits: HashMap, + /// By slot: the render shown and what it shows. + pub(crate) shown: HashMap, + /// By slot: the render the worker is making. + pending: HashMap, + /// The panes the last frame drew, as (window, pane rect). + pub(crate) last: Vec<(Win, egui::Rect)>, + /// By slot: the size of the view's area its view was last fitted to in a pane, and the zoom and + /// center the fit gave. + fits: HashMap, } impl Panes { @@ -46,27 +51,27 @@ impl Panes { self.fits.clear(); } - /// Upload `img`, the render `key` describes. - fn upload(&mut self, ctx: &egui::Context, key: CacheKey, img: &vectorcraft_render::Rendered) { + /// Upload `img`, the render `key` describes, for `slot`. + fn upload(&mut self, ctx: &egui::Context, slot: Slot, key: CacheKey, img: &vectorcraft_render::Rendered) { let color = super::color_image(img); // Pixel Preview's document pixels show hard-edged, as on the canvas. let options = if key.pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; - match self.shown.get_mut(&key.doc) { + match self.shown.get_mut(&slot) { Some((k, tex)) => { tex.set(color, options); *k = key; } None => { - let tex = ctx.load_texture(format!("arrange-pane-{}", key.doc), color, options); - self.shown.insert(key.doc, (key, tex)); + let tex = ctx.load_texture(format!("arrange-pane-{}-{}", slot.0, slot.1), color, options); + self.shown.insert(slot, (key, tex)); } } } - /// Bring the render of the document `key` describes up to `key` (`size` pixels through `view`), - /// on the worker when there is one, then draw the latest render of that document through `xf`, - /// turned and scaled into the view it was rendered for. - fn draw(&mut self, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, frame: PaneFrame) { + /// Bring `slot`'s render up to `key` (`size` pixels through `view`), on the worker when there + /// is one, then draw the slot's latest render through `xf`, turned and scaled into the view it + /// was rendered for. + fn draw(&mut self, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, slot: Slot, frame: PaneFrame) { let (key, doc, ((w, h), view), opts) = frame; if !self.worker_started { self.worker_started = true; @@ -75,11 +80,12 @@ impl Panes { match self.worker.as_ref().map(ThumbWorker::poll) { Some(Some(done)) => { for d in done { - if self.pending.get(&d.key.doc) == Some(&d.key) { - self.pending.remove(&d.key.doc); + let (s, k) = d.key; + if self.pending.get(&s) == Some(&k) { + self.pending.remove(&s); } if let Some(img) = d.img { - self.upload(ctx, d.key, &img); + self.upload(ctx, s, k, &img); } } } @@ -90,25 +96,24 @@ impl Panes { } None => {} } - let uid = key.doc; - let current = self.shown.get(&uid).is_some_and(|(k, _)| *k == key) || self.pending.get(&uid) == Some(&key); + let current = self.shown.get(&slot).is_some_and(|(k, _)| *k == key) || self.pending.get(&slot) == Some(&key); if !current { let mut queued = false; if let Some(worker) = &self.worker { let (job_doc, job_opts) = (doc.clone(), opts.clone()); - queued = worker.submit(uid, key.clone(), Box::new(move |r| Some(r.render(&job_doc, w, h, view, &job_opts)))); + queued = worker.submit(slot, (slot, key.clone()), Box::new(move |r| Some(r.render(&job_doc, w, h, view, &job_opts)))); if queued { - self.pending.insert(uid, key.clone()); + self.pending.insert(slot, key.clone()); } else { self.worker = None; } } if !queued { let img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts); - self.upload(ctx, key, &img); + self.upload(ctx, slot, key, &img); } } - if let Some((k, tex)) = self.shown.get(&uid) { + if let Some((k, tex)) = self.shown.get(&slot) { // Document pixels: placed over the document rect they cover. if let Some([x0, y0, x1, y1]) = k.pixel { textured_quad(painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0)); @@ -140,21 +145,19 @@ pub(crate) fn cells(n: usize, area: egui::Rect) -> Vec { (0..n).map(|i| egui::Rect::from_min_max(pos2(x(i % cols), y(i / cols)), pos2(x(i % cols + 1), y(i / cols + 1)))).collect() } -/// `window.arrange.tile`: every open document in a pane of its own, each fitted to its pane. With -/// fewer than two documents open (when the menu item is grayed out) it leaves the view and `tiled` as -/// they are. The documents in floating windows come back under the tabs for it. +/// `window.arrange.tile`: every open document, and each further view of one, in a pane of its own, +/// each fitted to its pane. With fewer than two windows open (when the menu item is grayed out) it +/// leaves the view and `tiled` as they are. The windows floating come back under the tabs for it. pub(crate) fn tile(app: &mut VectorcraftApp) -> Result { - let n = app.session.documents().len(); + app.sync_views(); + let n = crate::doc_windows::windows(app).len(); if n < 2 { return Ok(json!({"tiled": app.ui.tiled, "panes": n})); } - app.sync_views(); crate::doc_windows::dock_all(app); app.ui.tiled = true; app.ui.home = None; - for v in &mut app.views { - v.fitted = false; - } + crate::doc_windows::unfit_all(app); Ok(json!({"tiled": app.ui.tiled, "panes": n})) } @@ -167,14 +170,14 @@ pub(crate) fn consolidate(app: &mut VectorcraftApp) -> Result bool { if app.session.documents().is_empty() { app.ui.tiled = false; } - let docked = crate::doc_windows::docked(app); + let docked = crate::doc_windows::docked_windows(app); let n = docked.len(); let tiling = app.ui.tiled && n > 1 && app.ui.screen_mode < 3 && !crate::menus::home_showing(app); if !tiling { @@ -185,77 +188,78 @@ pub(crate) fn tiled_area(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { app.canvas.panes.last.clear(); return false; } - // The current document's pane is the canvas, unless it is in a floating window. - let active = app.session.active_index().filter(|a| docked.contains(a)); + // The current window's pane is the canvas, unless it is a floating one. + let active = crate::doc_windows::current(app).filter(|a| docked.contains(a)); app.sync_views(); - let live: Vec = app.session.documents().iter().map(|d| d.uid as usize).collect(); - app.canvas.panes.shown.retain(|uid, _| live.contains(uid)); - app.canvas.panes.pending.retain(|uid, _| live.contains(uid)); - app.canvas.panes.fits.retain(|uid, _| live.contains(uid)); - // A document opened, closed, floated or docked changes the grid, and so does coming back to the - // panes from one document: each document fits its new pane. - if app.canvas.panes.last.iter().map(|(i, _)| *i).ne(docked.iter().copied()) { - for i in &docked { - if let Some(v) = app.views.get_mut(*i) { + let live: Vec = crate::doc_windows::windows(app).into_iter().filter_map(|w| slot(app, w)).collect(); + app.canvas.panes.shown.retain(|s, _| live.contains(s)); + app.canvas.panes.pending.retain(|s, _| live.contains(s)); + app.canvas.panes.fits.retain(|s, _| live.contains(s)); + // A window opened, closed, floated or docked changes the grid, and so does coming back to the + // panes from one window: each window fits its new pane. + if app.canvas.panes.last.iter().map(|(w, _)| *w).ne(docked.iter().copied()) { + for w in &docked { + if let Some(v) = crate::doc_windows::view_mut(app, *w) { v.fitted = false; } } } let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); - let cells: Vec<(usize, egui::Rect)> = docked.iter().copied().zip(cells(n, ui.available_rect_before_wrap())).collect(); - let mut areas: Vec> = vec![None; app.session.documents().len()]; - for (i, cell) in &cells { - if let Some(a) = areas.get_mut(*i) { - *a = Some(view_rects(app, *cell, ppp).1); - } - } + let cells: Vec<(Win, egui::Rect)> = docked.iter().copied().zip(cells(n, ui.available_rect_before_wrap())).collect(); + let areas: Vec<(Win, egui::Rect)> = cells.iter().map(|(w, cell)| (*w, view_rects(app, *cell, ppp).1)).collect(); let unfitted = refit_resized(app, &areas); let mut activate = None; // The other panes first, so that what the canvas draws past its pane (the Contextual Task Bar) // stays over them. - for (i, cell) in cells.iter().copied().filter(|(i, _)| Some(*i) != active) { - if pane(app, ui, i, cell) { - activate = Some(i); + for (w, cell) in cells.iter().copied().filter(|(w, _)| Some(*w) != active) { + if pane(app, ui, w, cell) { + activate = Some(w); } ui.painter().rect_stroke(cell, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); } - if let Some((_, cell)) = cells.iter().copied().find(|(i, _)| Some(*i) == active) { + if let Some((_, cell)) = cells.iter().copied().find(|(w, _)| Some(*w) == active) { let mut canvas = ui.new_child(egui::UiBuilder::new().max_rect(cell)); super::show(app, &mut canvas); ui.painter().rect_stroke(cell, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } // Note the fits this frame made, for `refit_resized`. - for ((i, area), was_unfitted) in areas.iter().enumerate().zip(unfitted) { - if let (Some(area), true, Some(v), Some(d)) = (area, was_unfitted, app.views.get(i).filter(|v| v.fitted), app.session.documents().get(i)) { - app.canvas.panes.fits.insert(d.uid as usize, (area.size(), v.zoom, v.center)); + for ((w, area), was_unfitted) in areas.iter().zip(unfitted) { + if let (true, Some(v), Some(s)) = (was_unfitted, crate::doc_windows::view(app, *w).filter(|v| v.fitted).copied(), slot(app, *w)) { + app.canvas.panes.fits.insert(s, (area.size(), v.zoom, v.center)); } } app.canvas.panes.last = cells; - if let Some(i) = activate { - crate::chrome::show_document(app, i); + if let Some(w) = activate { + crate::doc_windows::show_window(app, w); } true } -/// Unfit each document whose view is still where a pane fit left it when its view's area (in -/// `areas`, by document index; None for a document without a pane) has changed size, as when the -/// window or a panel is resized: it fits its pane again. A view zoomed or scrolled since keeps its -/// place. → which views are unfitted now. -fn refit_resized(app: &mut VectorcraftApp, areas: &[Option]) -> Vec { - let fits = &app.canvas.panes.fits; - let docs = app.session.documents(); - for ((v, d), area) in app.views.iter_mut().zip(docs).zip(areas) { - if let Some(area) = area - && let Some((size, zoom, center)) = fits.get(&(d.uid as usize)) - && *size != area.size() - && *zoom == v.zoom - && *center == v.center - { - v.fitted = false; - } - } - app.views.iter().map(|v| !v.fitted).collect() +/// Window `w`'s [`Slot`]. +fn slot(app: &VectorcraftApp, (i, n): Win) -> Option { + app.session.documents().get(i).map(|d| (d.uid, n)) +} + +/// Unfit each window whose view is still where a pane fit left it when its view's area (`areas`, +/// by window) has changed size, as when the window or a panel is resized: it fits its pane again. +/// A view zoomed or scrolled since keeps its place. → which of `areas`' views are unfitted now. +fn refit_resized(app: &mut VectorcraftApp, areas: &[(Win, egui::Rect)]) -> Vec { + areas + .iter() + .map(|(w, area)| { + let fit = slot(app, *w).and_then(|s| app.canvas.panes.fits.get(&s).copied()); + let Some(v) = crate::doc_windows::view_mut(app, *w) else { return false }; + if let Some((size, zoom, center)) = fit + && size != area.size() + && zoom == v.zoom + && center == v.center + { + v.fitted = false; + } + !v.fitted + }) + .collect() } /// Fit `v` to show its artboard (the first if it has gone) in `rect`, as Fit Artboard in Window @@ -268,41 +272,45 @@ fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect) { } } -/// The pane of document `i`, not the current one, in `cell`: its paper, art, grid, artboards, -/// guides and selected objects, and its rulers and the room of the canvas's scroll bars, so the -/// document stays where it is when the pane becomes the canvas. True when a press in the pane asks -/// for its document to become the current one. A floating document's window draws its document -/// with this too ([`crate::doc_windows`]). -pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, i: usize, cell: egui::Rect) -> bool { +/// The pane of window `w` (document `i`'s view `n`), not the current one, in `cell`: its paper, +/// art, grid, artboards, guides and selected objects, and its rulers and the room of the canvas's +/// scroll bars, so the document stays where it is when the pane becomes the canvas. True when a +/// press in the pane asks for its window to become the current one. A floating window draws its +/// view with this too ([`crate::doc_windows`]). +pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> bool { let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); let (full, rect) = view_rects(app, cell, ppp); - let Some(st) = app.session.documents().get(i) else { return false }; - if let Some(v) = app.views.get_mut(i).filter(|v| !v.fitted) { - fit_pane(v, &st.doc, rect); + let Some(st) = app.session.documents().get(w.0) else { return false }; + let doc = st.doc.clone(); + if let Some(v) = crate::doc_windows::view_mut(app, w).filter(|v| !v.fitted) { + fit_pane(v, &doc, rect); } - let v = app.views.get(i).copied().unwrap_or_default(); + let v = crate::doc_windows::view(app, w).copied().unwrap_or_default(); + let Some(st) = app.session.documents().get(w.0) else { return false }; + let slot = (st.uid, w.1); let xf = Xf::new(rect, &v); let painter = ui.painter_at(rect); - paint_paper(app, st, ui, &painter, &xf, rect, &t); + paint_paper(app, st, ui, &painter, &xf, rect, (&t, v.outline)); let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs)); let grids_in_back = app.session.prefs.grids_in_back; if let Some(look) = grid_look.filter(|_| grids_in_back) { grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look); } let size = |l: f32| ((l * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)); - let (w, h) = (size(rect.width()), size(rect.height())); + let (pw, ph) = (size(rect.width()), size(rect.height())); // View › Pixel Preview, as the canvas draws it. let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten(); let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps; - let mut key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images)); - // The current document's drag and Image Trace hold are not this document's. - (key.dragging, key.trace_source) = (false, None); + let mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images)); + // The current document's drag and Image Trace hold are not this document's, and this view has + // its own Outline or Preview. + (key.dragging, key.trace_source, key.outline) = (false, None, v.outline); let opts = frame_options(app, &key, st.shown_mask(), smooth_images); - let (fw, fh, view) = frame_view(pixel, (w, h), &v, ppp); + let (fw, fh, view) = frame_view(pixel, (pw, ph), &v, ppp); let doc = st.doc.clone(); - app.canvas.panes.draw(ui.ctx(), &painter, &xf, (key, doc, ((fw, fh), view), opts)); - let Some(st) = app.session.documents().get(i) else { return false }; + app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts)); + let Some(st) = app.session.documents().get(w.0) else { return false }; if let Some(look) = grid_look.filter(|_| !grids_in_back) { grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look); } @@ -327,7 +335,7 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, i: usize, cell: egui::Rect ui.painter().rect_filled(bar, 0.0, t.ruler); } } - let resp = ui.interact(cell, egui::Id::unique(("arrange-pane", st.uid)), Sense::click_and_drag()); + let resp = ui.interact(cell, egui::Id::unique(("arrange-pane", st.uid, w.1)), Sense::click_and_drag()); resp.contains_pointer() && ui.input(|inp| inp.pointer.any_pressed()) } diff --git a/crates/ui-egui/src/canvas/panes/tests.rs b/crates/ui-egui/src/canvas/panes/tests.rs index cdd6add6dc..91a0ca5737 100644 --- a/crates/ui-egui/src/canvas/panes/tests.rs +++ b/crates/ui-egui/src/canvas/panes/tests.rs @@ -45,7 +45,7 @@ fn button(pos: Pos2, pressed: bool) -> egui::Event { /// The documents' indices in the panes the last frame drew, and the panes. fn panes(app: &VectorcraftApp) -> (Vec, Vec) { - app.canvas.panes.last.iter().copied().unzip() + app.canvas.panes.last.iter().map(|(w, r)| (w.0, *r)).unzip() } #[test] @@ -198,7 +198,7 @@ fn closing_a_document_while_tiled_tiles_the_others() { app.run("window.arrange.tile", json!({})).unwrap(); frame(&mut app, &ctx, vec![]); assert_eq!(panes(&app).0, [0, 1, 2]); - let first = app.session.documents()[0].uid as usize; + let first = (app.session.documents()[0].uid, 1); assert!(app.canvas.panes.shown.contains_key(&first)); // The first document, not the current one, closes. app.run("file.close", json!({"index": 0})).unwrap(); @@ -220,7 +220,7 @@ fn an_edit_shows_in_its_pane_once_another_document_is_current() { app.run("document.activate", json!({"index": 0})).unwrap(); app.run("window.arrange.tile", json!({})).unwrap(); frame(&mut app, &ctx, vec![]); - let uid = app.session.documents()[1].uid as usize; + let uid = (app.session.documents()[1].uid, 1); let before = app.canvas.panes.shown.get(&uid).map(|(k, _)| k.revision); assert_eq!(before, Some(app.session.documents()[1].revision), "the second document's pane shows it"); app.run("document.activate", json!({"index": 1})).unwrap(); @@ -294,7 +294,7 @@ fn other_panes_show_pixel_preview() { app.ui.view.pixel_preview = true; app.views[0].zoom = 4.0; frame(&mut app, &ctx, vec![]); - let uid = app.session.documents()[0].uid as usize; + let uid = (app.session.documents()[0].uid, 1); let key = app.canvas.panes.shown.get(&uid).map(|(k, _)| k.clone()).unwrap(); let [x0, y0, x1, y1] = key.pixel.expect("the pane renders document pixels"); assert!(x1 > x0 && y1 > y0); diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 73a6334866..e6579d8f31 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -117,8 +117,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none())); } // As in the reference app, open paths offer Join and Edit Path, compound paths and clipping - // masks their Release, images selected with vector art Make Clipping Mask, and type converts - // between point and area type (). + // masks their Release, images selected with vector art Make Clipping Mask, type converts + // between point and area type, and type on a path offers its options (). if n > 1 { items.push((tl!("Group"), "group", "object.group", none())); if mixed { @@ -144,11 +144,12 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none())); } else if let Some(kind) = text { items.push((tl!("Create Outlines"), "type", "type.createOutlines", none())); - // Point type converts to area type and back; type on a path doesn't. + // Point type converts to area type and back. The overview above gives type on a path an item to add + // an effect and rearrange the text; here it opens Type on a Path Options. match kind { TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())), TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), - TextKind::OnPath { .. } => {} + TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), } } else if is_image { // Offset Path and Simplify don't apply to an image. diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index cac5949161..d81e2fb973 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -1,28 +1,37 @@ use super::{Sense, Stroke, Tokens, Ui, VectorcraftApp, icons, json, theme, vec2, widgets, zoom_label}; -/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited -/// "Name* @ 66.67 % (/Opacity Mask)". +/// [`view_tab_title`] for a document with one view. +#[cfg(test)] pub(super) fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String { + view_tab_title(d, None, zoom, outline, display_print_size) +} + +/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited +/// "Name* @ 66.67 % (/Opacity Mask)". For a view of a document with more than one +/// view, the view's `number` follows the name: "Name:2* @ 66.67 % (RGB/Preview)". +fn view_tab_title(d: &vectorcraft_engine::DocState, number: Option, zoom: f64, outline: bool, display_print_size: bool) -> String { let mode = if d.doc.mask_edit.is_some() { format!("<{0}>/{0}", tl!("Opacity Mask")) } else { let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" }; format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") }) }; - format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %")) + let number = number.map(|n| format!(":{n}")).unwrap_or_default(); + format!("{}{number}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %")) } -/// The title of open document `i`'s tab, with its own view's zoom and Outline or Preview. -pub(super) fn doc_tab_title(app: &VectorcraftApp, i: usize, d: &vectorcraft_engine::DocState) -> String { - let view = app.views.get(i); +/// The title of window `w`, a view of document `d`: with the view's zoom and Outline or Preview, +/// and the view's number while the document has more than one view. +fn window_tab_title(app: &VectorcraftApp, w: crate::doc_windows::Win, d: &vectorcraft_engine::DocState) -> String { + let view = crate::doc_windows::view(app, w); let zoom = view.map_or(1.0, |v| v.zoom); - tab_title(d, zoom, view.is_some_and(|v| v.outline), app.session.prefs.display_print_size) + view_tab_title(d, crate::doc_windows::label(app, w), zoom, view.is_some_and(|v| v.outline), app.session.prefs.display_print_size) } -/// The title of document `i`'s floating window ([`crate::doc_windows`]): its tab's title. -pub(crate) fn document_window_title(app: &VectorcraftApp, i: usize) -> Option { - let d = app.session.documents().get(i)?; - Some(doc_tab_title(app, i, d)) +/// The title of window `w`'s tab and floating window ([`crate::doc_windows`]). +pub(crate) fn document_window_title(app: &VectorcraftApp, w: crate::doc_windows::Win) -> Option { + let d = app.session.documents().get(w.0)?; + Some(window_tab_title(app, w, d)) } /// The height of the tab strip and the size of a tab's title, in points: larger with User @@ -31,16 +40,16 @@ fn tab_size(app: &VectorcraftApp) -> (f32, f32) { if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) } } -/// The tab at the top of document `i`'s floating OS window ([`crate::doc_windows`]): a strip with -/// the document's tab, as the main window's strip draws it but without its ×. → the tab's -/// response, which senses clicks and drags. -pub(crate) fn window_tab(app: &VectorcraftApp, ui: &mut Ui, i: usize) -> egui::Response { +/// The tab at the top of window `w`'s floating OS window ([`crate::doc_windows`]): a strip with the +/// window's tab, as the main window's strip draws it but without its ×. → the tab's response, +/// which senses clicks and drags. +pub(crate) fn window_tab(app: &VectorcraftApp, ui: &mut Ui, w: crate::doc_windows::Win) -> egui::Response { let t = Tokens::get(ui.ctx()); let (height, title_size) = tab_size(app); let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.tab_strip); ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); - let title = document_window_title(app, i).unwrap_or_default(); + let title = document_window_title(app, w).unwrap_or_default(); let galley = ui.painter().layout_no_wrap(title, theme::semibold(title_size), t.text_strong); let r = egui::Rect::from_min_size(strip.min, vec2((galley.size().x + 28.0).min(strip.width()), strip.height() - 1.0)); ui.painter().rect_filled(r, 0.0, t.panel); @@ -88,10 +97,10 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option, room: f32) /// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs /// taller, with larger titles. Tabs that don't fit are reached from a » button at the strip's /// right end, which lists every open document, and the active tab is always in view (#746). -/// Documents in floating windows have no tab ([`crate::doc_windows`]); while the current document -/// is in one, the tab of the document the strip shows is the highlighted one. A tab dragged more -/// than [`crate::doc_windows::TEAR_OFF`] above or below the strip floats its document in a window -/// under the pointer. +/// Each view Window › New Window opened has a tab of its own. Windows floating have no tab +/// ([`crate::doc_windows`]); while the current window is a floating one, the tab of the window the +/// strip shows is the highlighted one. A tab dragged more than [`crate::doc_windows::TEAR_OFF`] +/// above or below the strip floats its view in a window under the pointer. pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { /// The width of the » button. const MORE: f32 = 30.0; @@ -102,16 +111,16 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().rect_filled(strip, 0.0, t.tab_strip); ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); // On the Home screen no tab is the current one. - let shown = crate::doc_windows::tab_document(app).filter(|_| app.ui.home.is_none()); - // The documents under the tabs, by index; `active` is the position of the highlighted one. - let docked = crate::doc_windows::docked(app); - let active = docked.iter().position(|i| Some(*i) == shown); + let shown = crate::doc_windows::tab_window(app).filter(|_| app.ui.home.is_none()); + // The windows under the tabs; `active` is the position of the highlighted one. + let docked = crate::doc_windows::docked_windows(app); + let active = docked.iter().position(|w| Some(*w) == shown); let tabs: Vec<(String, std::sync::Arc)> = docked .iter() - .filter_map(|i| Some((*i, app.session.documents().get(*i)?))) - .map(|(i, d)| { - let title = doc_tab_title(app, i, d); - let color = if Some(i) == shown { t.text_strong } else { t.text_dim }; + .filter_map(|w| Some((*w, app.session.documents().get(w.0)?))) + .map(|(w, d)| { + let title = window_tab_title(app, w, d); + let color = if Some(w) == shown { t.text_strong } else { t.text_dim }; (title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color)) }) .collect(); @@ -128,10 +137,11 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { if x + w > strip.left() + room && Some(k) != active { break; } - let Some(&i) = docked.get(k) else { break }; + let Some(&win) = docked.get(k) else { break }; + let i = win.0; let is_active = Some(k) == active; let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(*w, strip.height() - 1.0)); - let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with(("tab", i, win.1)), Sense::click_and_drag()); if is_active { ui.painter().rect_filled(r, 0.0, t.panel); } else if resp.hovered() { @@ -142,26 +152,27 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { // (#673). let (x_center, title_left) = if CLOSE_BEFORE_TITLE { (r.left() + 16.0, r.left() + 32.0) } else { (r.right() - 16.0, r.left() + 14.0) }; let xr = egui::Rect::from_center_size(egui::pos2(x_center, r.center().y), vec2(12.0, 12.0)); - let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i)), Sense::click()); + let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i, win.1)), Sense::click()); icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text); - hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0)); + hovering_art |= crate::document_drag::hover_tab(app, &resp, win, xr.expand(3.0)); if xresp.clicked() { - close = Some(i); + close = Some(win); } else if resp.clicked() { - activate = Some(i); + activate = Some(win); } else if let Some(at) = tear_off(ui, &resp, strip) { - float = Some((i, at)); + float = Some((win, at)); } x += w; } crate::document_drag::finish_tabs(ui.ctx(), hovering_art); - // The » button: every open document, floating ones included, the active one checked. The list + // The » button: every window, floating ones included, the current one checked. The list // scrolls once it reaches the bottom of the window, so every document of a large session stays // reachable (#4017). if overflow { - let current = app.session.active_index().filter(|_| app.ui.home.is_none()); - let titles: Vec = (0..app.session.documents().len()).map(|i| document_window_title(app, i).unwrap_or_default()).collect(); + let current = crate::doc_windows::current(app).filter(|_| app.ui.home.is_none()); + let all = crate::doc_windows::windows(app); + let titles: Vec<(crate::doc_windows::Win, String)> = all.iter().map(|w| (*w, document_window_title(app, *w).unwrap_or_default())).collect(); let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0)); let resp = ui.interact(more, ui.scope_id().with("tabs-more"), Sense::click()); ui.painter().rect_filled(more, 0.0, if resp.hovered() { t.hover.gamma_multiply(0.4) } else { t.tab_strip }); @@ -169,25 +180,25 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { let resp = resp.on_hover_text(tl!("Show all open documents")); egui::Popup::menu(&resp).show(|ui| { widgets::menu_scroll(ui, |ui| { - for (i, title) in titles.iter().enumerate() { - if widgets::menu_item_name(ui, title, true, Some(i) == current) { - activate = Some(i); + for (w, title) in &titles { + if widgets::menu_item_name(ui, title, true, Some(*w) == current) { + activate = Some(*w); ui.close(); } } }); }); } - if let Some(i) = close { - if let Err(e) = crate::unsaved::close(app, i) { + if let Some(w) = close { + if let Err(e) = crate::doc_windows::close_window(app, w) { app.status(e); } - } else if let Some((i, at)) = float { - if let Err(e) = app.run("window.arrange.floatInWindow", json!({"index": i, "at": [at.x, at.y]})) { + } else if let Some(((i, n), at)) = float { + if let Err(e) = app.run("window.arrange.floatInWindow", json!({"index": i, "view": n, "at": [at.x, at.y]})) { app.status(e); } - } else if let Some(i) = activate { - show_document(app, i); + } else if let Some(w) = activate { + crate::doc_windows::show_window(app, w); } } @@ -212,20 +223,27 @@ pub(crate) fn show_document(app: &mut VectorcraftApp, i: usize) { /// the current one in tab order (before it) the current one, as a click on its tab does. After the /// last document comes the first, and before the first document comes the last. On the Home /// screen, where no tab is the current one, the next document is the first and the previous -/// document is the last. Returns `{index}`. +/// document is the last. Each view Window › New Window opened is a step of its own. Returns +/// `{index}`, with the view's number as `view` while the document has more than one view. pub fn step_document(app: &mut VectorcraftApp, forward: bool) -> Result { - let n = app.session.documents().len(); + let all = crate::doc_windows::windows(app); + let n = all.len(); if n == 0 { return Err("no document open".into()); } - let i = match (app.session.active_index().filter(|_| app.ui.home.is_none()), forward) { + let current = crate::doc_windows::current(app).filter(|_| app.ui.home.is_none()).and_then(|c| all.iter().position(|w| *w == c)); + let k = match (current, forward) { (Some(a), true) => (a + 1) % n, (Some(a), false) => (a + n - 1) % n, (None, true) => 0, (None, false) => n - 1, }; - show_document(app, i); - Ok(json!({ "index": i })) + let Some(&w) = all.get(k) else { return Err("no document open".into()) }; + crate::doc_windows::show_window(app, w); + Ok(match crate::doc_windows::label(app, w) { + Some(view) => json!({ "index": w.0, "view": view }), + None => json!({ "index": w.0 }), + }) } /// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 774de75405..a2773f5f7b 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -873,7 +873,7 @@ fn only_the_outlined_documents_tab_reads_outline() { app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); app.run("view.outline", json!({})).unwrap(); - let titles: Vec = app.session.documents().iter().enumerate().map(|(i, d)| super::tabs::doc_tab_title(&app, i, d)).collect(); + let titles: Vec = (0..app.session.documents().len()).filter_map(|i| super::document_window_title(&app, (i, 1))).collect(); assert!(titles[0].ends_with("(RGB/Preview)"), "{titles:?}"); assert!(titles[1].ends_with("(RGB/Outline)"), "{titles:?}"); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d3278e97ea..d25a08f94d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -192,17 +192,23 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), // Window › Arrange › Tile (not saved, so not in `ui`) and the panes the last frame drew: - // each one's document index and rect. Canvas coordinates map to the current document's - // pane, inside its rulers and scroll bars (`canvasRect`). + // each one's document index, view number (Window › New Window) and rect. Canvas + // coordinates map to the current window's pane, inside its rulers and scroll bars + // (`canvasRect`). "tiled": app.ui.tiled, - "panes": app.canvas.panes.last.iter().map(|(i, c)| json!({ - "document": i, + "panes": app.canvas.panes.last.iter().map(|(w, c)| json!({ + "document": w.0, + "view": w.1, "rect": [c.left(), c.top(), c.width(), c.height()], - "active": app.session.active_index() == Some(*i), + "active": crate::doc_windows::current(app) == Some(*w), })).collect::>(), - // Window › Arrange › Float in Window: the documents in floating windows, by index. While - // the current one floats, `canvasRect` is in its window's coordinates. - "floating": (0..app.session.documents().len()).filter(|i| crate::doc_windows::is_floating(app, *i)).collect::>(), + // Window › Arrange › Float in Window: the floating windows' documents, by index. While the + // current one floats, `canvasRect` is in its window's coordinates. + "floating": crate::doc_windows::floating_windows(app).iter().map(|w| w.0).collect::>(), + // Window › New Window: every window, as [document index, view number], in tab order, and + // the current one. + "windows": crate::doc_windows::windows(app), + "currentWindow": crate::doc_windows::current(app), "window": [r.width(), r.height()], "documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::>(), "activeDocument": app.session.active_index(), diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 440f94c6e5..d344f59e7d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -264,10 +264,14 @@ pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ui.label(RichText::new(counts).small().weak()); ui.separator(); if v.table && !list.is_empty() { - // The table scrolls its rows under its header itself, and sideways as a whole. - egui::ScrollArea::horizontal().auto_shrink([false, true]).show(ui, |ui| table(app, ui, &list, &mut v)); + // The table scrolls its rows under its header itself; it only scrolls sideways once its + // columns are dragged wider than the dialog. + ui.allocate_ui(egui::vec2(ui.available_width(), SCROLL_HEIGHT), |ui| { + ui.set_max_height(SCROLL_HEIGHT); + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(app, ui, &list, &mut v)); + }); } else { - egui::ScrollArea::both().max_height(SCROLL_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(SCROLL_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { if list.is_empty() { ui.label(tl!("No contributor data was built into this copy.")); } else { @@ -294,20 +298,120 @@ fn link(app: &mut VectorcraftApp, ui: &mut egui::Ui, c: &Contributor, names: Nam r } +/// How the contributor table's spare width is shared out: all of it to Name. +const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + +/// How the model table's spare width is shared out: to Company and Model, the text columns. +const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0]; + +/// The narrowest a column can be dragged. +const MIN_COLUMN: f32 = 28.0; + +/// The width a column needs for its header and its widest cell. +fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator) -> f32 { + let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x; + let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max); + widest + 2.0 * ui.spacing().button_padding.x +} + +/// Opening column widths: each column's natural width, with the spare room shared out by `grow`, +/// so the columns fill `room` exactly. When there is too little room, only the growing columns +/// give way, down to [`MIN_COLUMN`]. +fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec { + let spare = room - natural.iter().sum::(); + let weight = grow.iter().sum::(); + let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum(); + natural + .iter() + .zip(grow) + .map(|(w, g)| { + if spare >= 0.0 && weight > 0.0 { + w + spare * g / weight + } else if *g > 0.0 && flexible > 0.0 { + (w + spare * w / flexible).max(MIN_COLUMN) + } else { + *w + } + }) + .collect() +} + +/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls +/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills +/// whatever width is left but never shrinks below its opening width, so widening a column grows +/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider +/// than the window scrolls sideways. Text too long for its column is clipped. +fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> { + // Every cell is followed by the item gap, and the vertical scrollbar keeps its margins. + let s = ui.spacing(); + let gaps = s.item_spacing.x * natural.len() as f32; + let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin; + let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0)); + let mut t = + egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY); + let last = widths.len().saturating_sub(1); + for (i, w) in widths.into_iter().enumerate() { + let c = if i == last { + egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN)) + } else { + egui_extras::Column::initial(w).at_least(MIN_COLUMN) + }; + t = t.column(c.clip(true)); + } + t +} + +/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred. +fn aligned(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, add).inner +} + +fn row_height(ui: &egui::Ui) -> f32 { + ui.text_style_height(&egui::TextStyle::Body) + 6.0 +} + +/// One contributor's table cells after the name, in `SortKey::ALL` order. +fn cells(c: &Contributor) -> [String; 9] { + [ + group(c.prs), + group(c.commits), + group(c.lines_added), + group(c.lines_deleted), + signed(c.lines_delta()), + group(c.binary_added), + group(c.binary_deleted), + day(c.first_commit).to_string(), + day(c.last_commit).to_string(), + ] +} + +/// A contributor-table header. The sorted column is highlighted; its direction shows on the +/// toolbar's sort-order button, so no arrow widens the column. +fn header_text(k: SortKey) -> RichText { + RichText::new(k.label().1).strong() +} + /// The contributors as a table whose header row, the sort buttons, stays above the rows as they /// scroll (#4944). fn table(app: &mut VectorcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { - let (row, names) = (ui.spacing().interact_size.y, v.names); - egui_extras::TableBuilder::new(ui) - .id_salt("credits_table") - .striped(true) - .columns(egui_extras::Column::auto(), SortKey::ALL.len()) - .max_scroll_height(SCROLL_HEIGHT - row) - .header(row, |mut header| { + let h = row_height(ui); + let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect(); + let widths: Vec = SortKey::ALL + .iter() + .enumerate() + .map(|(i, &k)| match i.checked_sub(1) { + None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))), + Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())), + }) + .collect(); + fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW) + .header(h + 4.0, |mut header| { for k in SortKey::ALL { header.col(|ui| { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() { + let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0); + + if r.clicked() { if v.key == k { v.ascending = !v.ascending; } else { @@ -319,25 +423,14 @@ fn table(app: &mut VectorcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: } }) .body(|mut body| { - for c in list { - body.row(row, |mut r| { - r.col(|ui| { - link(app, ui, c, names); + for (c, cells) in list.iter().zip(rows) { + body.row(h, |mut row| { + row.col(|ui| { + aligned(ui, false, |ui| link(app, ui, c, v.names)); }); - let cells = [ - group(c.prs), - group(c.commits), - group(c.lines_added), - group(c.lines_deleted), - signed(c.lines_delta()), - group(c.binary_added), - group(c.binary_deleted), - day(c.first_commit).to_string(), - day(c.last_commit).to_string(), - ]; for text in cells { - r.col(|ui| { - ui.label(text); + row.col(|ui| { + aligned(ui, true, |ui| ui.label(text)); }); } }); @@ -352,21 +445,50 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().max_height(SCROLL_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { - egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in [tl!("Company"), tl!("Model"), tl!("Version"), tl!("Commits"), tl!("% of all commits"), tl!("Lines +/−")] { - ui.label(RichText::new(h).strong()); - } - ui.end_row(); - for m in MODELS { - ui.label(m.company); - ui.label(m.model); - ui.label(m.version); - ui.label(group(m.commits)); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); - ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); - ui.end_row(); - } + let h = row_height(ui); + let titles = [tl!("Company"), tl!("Model"), tl!("Version"), tl!("Commits"), tl!("% of all commits"), tl!("Lines +/−")]; + // Company, Model and Version read as text; the counts line up on the right. + let right = [false, false, false, true, true, true]; + let rows: Vec<[String; 6]> = MODELS + .iter() + .map(|m| { + [ + m.company.to_string(), + m.model.to_string(), + m.version.to_string(), + group(m.commits), + format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64), + format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)), + ] + }) + .collect(); + let widths: Vec = titles + .iter() + .enumerate() + .map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned()))) + .collect(); + ui.allocate_ui(egui::vec2(ui.available_width(), SCROLL_HEIGHT), |ui| { + ui.set_max_height(SCROLL_HEIGHT); + egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| { + fitted_table(ui, "credits_models", &widths, &MODEL_GROW) + .header(h + 4.0, |mut header| { + for (title, r) in titles.into_iter().zip(right) { + header.col(|ui| { + aligned(ui, r, |ui| ui.label(RichText::new(title).strong())); + }); + } + }) + .body(|mut body| { + for cells in rows { + body.row(h, |mut row| { + for (text, r) in cells.into_iter().zip(right) { + row.col(|ui| { + aligned(ui, r, |ui| ui.label(text)); + }); + } + }); + } + }); }); }); } @@ -494,4 +616,19 @@ mod tests { assert_eq!(after[0], Some(header), "the header stays where it was"); assert!(after[1].is_none_or(|p| p.y < row.y), "the rows scrolled: {:?} from {row:?}", after[1]); } + + #[test] + fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]); + let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0); + assert!((w.iter().sum::() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}"); + } + + #[test] + fn too_little_room_shrinks_only_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]); + } } diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index 1e459a7fb4..fa2be21c00 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -4,13 +4,16 @@ //! `toolbar` param of its commands). Its title bar moves it, its × hides it and its double arrow //! shows its tools in one column or two. Each toolbar is listed under Window › Toolbars, where its //! item shows or hides it, and is kept with the workspace it was made in (`Workspace::toolbars`). +//! Window › Toolbars › Manage Toolbars (also in the drawer's menu) renames, copies and deletes them. //! //! Behavior from the public documentation: //! (Create and manage toolbars: New Toolbar //! from Window › Toolbars or the drawer's flyout menu, a name and OK make a blank toolbar, its Edit //! Toolbar button opens the drawer to add tools, and a toolbar stays in the workspace it is made in; //! a toolbar moves by its title bar, whose double arrow toggles one column or two and whose close -//! button hides it). +//! button hides it. Manage toolbars: Manage Toolbars from Window › Toolbars or the drawer's flyout +//! menu; a new name typed for the selected toolbar renames it, + copies the selected toolbar or, +//! with none selected, makes a new one, and Delete deletes it). use std::sync::atomic::Ordering; @@ -118,6 +121,97 @@ fn show_alone_after_tab(ui: &mut UiState, name: &str) { } } +/// Where the toolbar named `name` is in the list, whatever its case. +fn index(ui: &UiState, name: &str) -> Result { + let k = key(name); + ui.custom_toolbars.iter().position(|b| key(&b.name) == k).ok_or_else(|| format!("no toolbar named `{name}`")) +} + +/// The All Tools drawer beside the toolbar named `old`, and a group's flyout open on it (`ctx`), +/// close when it is deleted (`new` is `None`) or renamed; a new name that changes only the case +/// leaves them open on it. +fn close_popups(ui: &mut UiState, ctx: Option<&egui::Context>, old: &str, new: Option<&str>) { + let same = new.is_some_and(|n| key(n) == key(old)); + if drawer_target(ui).map(key) == Some(key(old)) { + if same { + ui.drawer_toolbar = new.map(String::from); + } else { + ui.tools_drawer = false; + ui.drawer_toolbar = None; + } + } + let Some(ctx) = ctx else { return }; + let id = crate::toolbar::flyout_toolbar_id(); + if ui.flyout.is_some() && ctx.data(|d| d.get_temp::>(id)).flatten().is_some_and(|t| key(&t) == key(old)) { + if same { + ctx.data_mut(|d| d.insert_temp(id, new.map(String::from))); + } else { + ui.flyout = None; + } + } +} + +/// Give the toolbar named `name` the name `to`; returns the new name. +pub(crate) fn rename(ui: &mut UiState, ctx: Option<&egui::Context>, name: &str, to: &str) -> Result { + let to = to.trim(); + if to.is_empty() { + return Err("toolbar name can't be empty".into()); + } + if to.chars().count() > MAX_NAME { + return Err(format!("a toolbar name has at most {MAX_NAME} characters")); + } + let k = index(ui, name)?; + if ui.custom_toolbars.iter().enumerate().any(|(i, b)| i != k && key(&b.name) == key(to)) { + return Err(format!("there is already a toolbar named `{to}`")); + } + let old = ui.custom_toolbars.get(k).map(|b| b.name.clone()).unwrap_or_default(); + close_popups(ui, ctx, &old, Some(to)); + if let Some(b) = ui.custom_toolbars.get_mut(k) { + b.name = to.to_string(); + } + Ok(to.to_string()) +} + +/// A copy of the toolbar named `name`, with its tools, shown a little below it (further down +/// while an earlier copy is there) and called +/// "`name` Copy" (then "`name` Copy 2"…); without `name`, a blank toolbar called "Toolbar N". +/// Returns the new toolbar's name. +pub(crate) fn duplicate(ui: &mut UiState, name: Option<&str>) -> Result { + let Some(name) = name else { + let name = (1..).map(|i| format!("Toolbar {i}")).find(|n| find(ui, n).is_none()).unwrap_or_default(); + return create(ui, &name); + }; + let k = index(ui, name)?; + if ui.custom_toolbars.len() >= MAX { + return Err(format!("a workspace keeps at most {MAX} toolbars")); + } + let Some(mut copy) = ui.custom_toolbars.get(k).cloned() else { return Err(format!("no toolbar named `{name}`")) }; + // A long name is shortened so that "Copy" and its number fit in MAX_NAME characters. + let stem: String = copy.name.chars().take(MAX_NAME - " Copy 64".len()).collect(); + let base = format!("{} Copy", stem.trim_end()); + copy.name = std::iter::once(base.clone()).chain((2..).map(|i| format!("{base} {i}"))).find(|n| find(ui, n).is_none()).unwrap_or_default(); + copy.shown = true; + let from = copy.pos; + let at = |n: f32| [from[0] + 16.0 * n, from[1] + 24.0 * n]; + let n = (1..=MAX + 1).map(|n| n as f32).find(|&n| ui.custom_toolbars.iter().all(|b| b.pos != at(n))).unwrap_or(1.0); + copy.pos = at(n); + for f in &mut copy.floating { + f.pos = [f.pos[0] + 16.0 * n, f.pos[1] + 24.0 * n]; + } + let name = copy.name.clone(); + ui.custom_toolbars.push(copy); + show_alone_after_tab(ui, &name); + Ok(name) +} + +/// Delete the toolbar named `name`, with the groups torn off it. +pub(crate) fn delete(ui: &mut UiState, ctx: Option<&egui::Context>, name: &str) -> Result<(), String> { + let k = index(ui, name)?; + close_popups(ui, ctx, name, None); + ui.custom_toolbars.remove(k); + Ok(()) +} + /// Keep named toolbars, each name once whatever its case and at most [`MAX_NAME`] characters, of /// known tools, each once in its toolbar, at a position that is a number, with one strip per group /// torn off them, and each group's tool chosen last only when the group holds it (a hand-edited @@ -190,6 +284,26 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::new(MANAGE, json!({ "selected": "", "name": "" }))); + Ok(Value::Null) + } + "window.toolbar.rename" => (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + let to = p.get("to").and_then(Value::as_str).ok_or("to (the new name) is required")?; + rename(&mut app.ui, app.ui_ctx.as_ref(), name, to).map(|name| json!({ "toolbar": name })) + })(), + "window.toolbar.duplicate" => (|| { + let name = match p.get("name").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_str().ok_or("name must be a toolbar's name")?), + None => None, + }; + duplicate(&mut app.ui, name).map(|name| json!({ "toolbar": name })) + })(), + "window.toolbar.delete" => (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + delete(&mut app.ui, app.ui_ctx.as_ref(), name).map(|_| Value::Null) + })(), "window.toolbar.list" => Ok(json!( app.ui .custom_toolbars @@ -250,6 +364,143 @@ pub(crate) fn show_dialog(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// Manage Toolbars' dialog. +pub(crate) const MANAGE: &str = "manageToolbars"; + +/// The name typed in Manage Toolbars' text box becomes the selected toolbar's (a no-op while it is +/// the same). Returns true when the selected toolbar was deleted or renamed while the dialog was +/// open (a command, another workspace): none is selected then, and the name typed for it is dropped. +fn rename_selected(app: &mut VectorcraftApp) -> Result { + let Some(d) = app.ui.dialog.as_ref() else { return Ok(false) }; + let (sel, name) = (d.str("selected"), d.str("name")); + if !sel.is_empty() && find(&app.ui, &sel).is_none() { + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("selected".into(), json!("")); + d.fields.insert("name".into(), json!("")); + } + return Ok(true); + } + if sel.is_empty() || sel == name.trim() { + return Ok(false); + } + let new = app.run("window.toolbar.rename", json!({ "name": sel, "to": name }))?; + let new = new.get("toolbar").and_then(Value::as_str).unwrap_or_default().to_string(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("selected".into(), json!(new)); + d.fields.insert("name".into(), json!(new)); + } + Ok(false) +} + +/// OK in Manage Toolbars: the typed name is kept, and the dialog closes. +pub(crate) fn confirm_manage(app: &mut VectorcraftApp) -> Result { + rename_selected(app)?; + app.ui.dialog = None; + Ok(Value::Null) +} + +/// The Manage Toolbars dialog: the toolbars made with New Toolbar; the selected one's name in the +/// text box, where a new name renames it; + copies the selected toolbar (none selected: makes a +/// blank one), and the trash can deletes it. +pub(crate) fn show_manage(app: &mut VectorcraftApp, ctx: &egui::Context) { + let Some(mut d) = app.ui.dialog.clone() else { return }; + let t = Tokens::get(ctx); + let (mut ok, mut commit) = (false, false); + // A toolbar clicked in the list (none: the click left none selected), + (true) or the trash + // can (false): each applied after the name typed for the selected toolbar. + let mut pick: Option = None; + let mut action: Option = None; + crate::dialogs::modal::show(ctx, "Manage Toolbars", egui::Id::unique("dialog-manage-toolbars"), -40.0, 22, |ui| { + ui.set_width(320.0); + crate::dialogs::modal::heading(ui, tl!("Manage Toolbars")); + ui.add_space(12.0); + let sel = d.str("selected"); + egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| { + ui.set_min_height(140.0); + ui.set_width(ui.available_width()); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for b in &app.ui.custom_toolbars { + let on = !sel.is_empty() && key(&sel) == key(&b.name); + if ui.selectable_label(on, egui::RichText::new(&b.name).color(t.text)).clicked() { + // A click on the selected toolbar leaves none selected. + pick = Some(if on { String::new() } else { b.name.clone() }); + } + } + }); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + let mut name = d.str("name"); + let r = ui.add_enabled(!sel.is_empty(), egui::TextEdit::singleline(&mut name).desired_width(220.0)); + if r.changed() { + d.fields.insert("name".into(), json!(name)); + } + commit = r.lost_focus(); + let tip = if sel.is_empty() { tl!("New Toolbar") } else { tl!("Duplicate Toolbar") }; + if widgets::icon_button(ui, "plus", tip, false, 24.0).clicked() { + action = Some(true); + } + if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Toolbar"), false, !sel.is_empty(), 24.0).clicked() { + action = Some(false); + } + }); + ui.add_space(16.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::primary_button(ui, tl!("OK")).clicked() { + ok = true; + } + }); + }); + app.ui.dialog = Some(d); + if ok { + if let Err(e) = confirm_manage(app) { + app.status(e); + } + return; + } + manage_step(app, commit, pick, action); +} + +/// What the Manage Toolbars dialog does after a frame: the name typed for the selected toolbar is +/// applied when the text box loses focus, another toolbar is clicked (`pick`) or + (`Some(true)`) +/// or the trash can (`Some(false)`) is clicked. When that rename fails, the click is not applied, +/// so the name typed can be corrected; when the selected toolbar has gone meanwhile, + and the trash +/// can are not applied either. +fn manage_step(app: &mut VectorcraftApp, commit: bool, pick: Option, mut action: Option) { + if commit || pick.is_some() || action.is_some() { + match rename_selected(app) { + Err(e) => { + app.status(e); + return; + } + // The toolbar + or the trash can was meant for is gone. + Ok(true) => action = None, + Ok(false) => {} + } + } + let sel = app.ui.dialog.as_ref().map(|d| d.str("selected")).unwrap_or_default(); + let done = match action { + Some(true) => Some(app.run("window.toolbar.duplicate", if sel.is_empty() { json!({}) } else { json!({ "name": sel }) })), + Some(false) => Some(app.run("window.toolbar.delete", json!({ "name": sel }))), + None => None, + }; + // The new toolbar is selected, ready to be renamed; after Delete, none is. + let select = match done { + Some(Ok(r)) => Some(r.get("toolbar").and_then(Value::as_str).unwrap_or_default().to_string()), + Some(Err(e)) => { + app.status(e); + None + } + None => pick, + }; + if let Some(name) = select + && let Some(d) = app.ui.dialog.as_mut() + { + d.fields.insert("selected".into(), json!(name)); + d.fields.insert("name".into(), json!(name)); + } +} + // ---------- the menu ---------- crate::shortcut_editor::ui_mirror!(fn names_store() -> Vec<&'static str>); @@ -269,7 +520,7 @@ pub fn sync(ui: &UiState) { } /// Window › Toolbars: Advanced and Double Column, the toolbars made with New Toolbar (each checked -/// while it shows), and New Toolbar…. +/// while it shows), New Toolbar… and Manage Toolbars…. pub fn menu_items() -> Vec { use crate::menus::Item; let c = |label: &'static str, id: &'static str| Item::Cmd(label, id, Value::Null); @@ -280,6 +531,7 @@ pub fn menu_items() -> Vec { v.push(Item::Sep); } v.push(c("New Toolbar…", "window.toolbar.new")); + v.push(c("Manage Toolbars…", "window.toolbar.manage")); v } @@ -567,7 +819,8 @@ mod tests { #[test] fn a_new_toolbar_is_listed_under_window_toolbars() { let mut app = app(); - assert_eq!(toolbars_menu(&app).last().map(|(l, id, _)| (*l, *id)), Some(("New Toolbar…", "window.toolbar.new"))); + let last: Vec<_> = toolbars_menu(&app).iter().rev().take(2).map(|(l, id, _)| (*l, *id)).collect(); + assert_eq!(last, [("Manage Toolbars…", "window.toolbar.manage"), ("New Toolbar…", "window.toolbar.new")]); assert_eq!(app.run("window.toolbar.new", json!({"name": " Mine "})).unwrap(), json!({"toolbar": "Mine"})); let menu = toolbars_menu(&app); let item = menu.iter().find(|(l, ..)| *l == "Mine").expect("Mine is listed"); @@ -1060,6 +1313,264 @@ mod tests { assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle)); } + fn listed(app: &VectorcraftApp) -> Vec<&'static str> { + toolbars_menu(app).into_iter().filter(|(_, id, _)| *id == "window.toolbar.custom").map(|(l, ..)| l).collect() + } + + /// Manage Toolbars renames, copies and deletes the toolbars made with New Toolbar: each change + /// shows under Window › Toolbars and stays in the workspace after switching away and back. + #[test] + fn manage_toolbars_renames_duplicates_and_deletes() { + let mut app = app(); + let menu = toolbars_menu(&app); + assert_eq!(menu.last().map(|(l, id, _)| (*l, *id)), Some(("Manage Toolbars…", "window.toolbar.manage"))); + app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + let back = |app: &mut VectorcraftApp| { + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Drawing"})).unwrap(); + }; + // Rename. + assert_eq!(app.run("window.toolbar.rename", json!({"name": "mine", "to": " Pens "})).unwrap(), json!({"toolbar": "Pens"})); + assert_eq!(listed(&app), ["Pens", "Other"]); + back(&mut app); + assert_eq!(listed(&app), ["Pens", "Other"]); + assert_eq!(find(&app.ui, "Pens").unwrap().slots, [["pen"]]); + assert!(app.run("window.toolbar.rename", json!({"name": "Pens", "to": "other"})).unwrap_err().contains("already")); + assert!(app.run("window.toolbar.rename", json!({"name": "Pens", "to": " "})).unwrap_err().contains("empty")); + assert!(app.run("window.toolbar.rename", json!({"name": "Nope", "to": "X"})).unwrap_err().contains("Nope")); + // Only the case changes. + app.run("window.toolbar.rename", json!({"name": "Pens", "to": "PENS"})).unwrap(); + app.run("window.toolbar.rename", json!({"name": "pens", "to": "Pens"})).unwrap(); + // Duplicate: a copy with its tools, shown; without a name, a blank toolbar. + app.run("window.toolbar.custom", json!({"name": "Pens", "show": false})).unwrap(); + assert_eq!(app.run("window.toolbar.duplicate", json!({"name": "pens"})).unwrap(), json!({"toolbar": "Pens Copy"})); + assert_eq!(app.run("window.toolbar.duplicate", json!({"name": "Pens"})).unwrap(), json!({"toolbar": "Pens Copy 2"})); + assert_eq!(app.run("window.toolbar.duplicate", json!({})).unwrap(), json!({"toolbar": "Toolbar 1"})); + let copy = find(&app.ui, "Pens Copy").unwrap(); + assert!(copy.shown && copy.slots == [["pen"]]); + assert_ne!(copy.pos, find(&app.ui, "Pens").unwrap().pos, "not on top of the toolbar it copies"); + assert_ne!(copy.pos, find(&app.ui, "Pens Copy 2").unwrap().pos, "nor on top of an earlier copy"); + assert!(find(&app.ui, "Toolbar 1").unwrap().slots.is_empty()); + assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy", "Pens Copy 2", "Toolbar 1"]); + // The copy is a toolbar of its own. + app.run("toolbar.addTool", json!({"tool": "rectangle", "toolbar": "Pens Copy"})).unwrap(); + assert_eq!(find(&app.ui, "Pens").unwrap().slots, [["pen"]]); + back(&mut app); + assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy", "Pens Copy 2", "Toolbar 1"]); + assert!(app.run("window.toolbar.duplicate", json!({"name": "Nope"})).is_err()); + // Delete. + app.run("window.toolbar.delete", json!({"name": "pens copy 2"})).unwrap(); + app.run("window.toolbar.delete", json!({"name": "Toolbar 1"})).unwrap(); + assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy"]); + back(&mut app); + assert_eq!(listed(&app), ["Pens", "Other", "Pens Copy"]); + assert!(app.run("window.toolbar.delete", json!({"name": "Toolbar 1"})).unwrap_err().contains("Toolbar 1")); + // Kept in the saved workspace and the preferences. + app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap(); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + let mut ui = prefs.sanitized(); + crate::workspaces::switch(&mut ui, "Essentials").unwrap(); + crate::workspaces::switch(&mut ui, "Drawing").unwrap(); + assert_eq!(ui.custom_toolbars.iter().map(|b| b.name.as_str()).collect::>(), ["Pens", "Other", "Pens Copy"]); + } + + /// Renaming or deleting the toolbar the All Tools drawer edits closes the drawer; a new name + /// that changes only the case leaves it open. + #[test] + fn renaming_or_deleting_the_toolbar_being_edited_closes_the_drawer() { + let mut app = app(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + app.run("window.editToolbar", json!({"toolbar": "Mine", "open": true})).unwrap(); + app.run("window.toolbar.rename", json!({"name": "Other", "to": "Second"})).unwrap(); + assert_eq!(drawer_target(&app.ui), Some("Mine"), "another toolbar's rename leaves it open"); + app.run("window.toolbar.rename", json!({"name": "Mine", "to": "MINE"})).unwrap(); + assert_eq!(drawer_target(&app.ui), Some("MINE"), "a change of case leaves it open"); + app.run("window.toolbar.rename", json!({"name": "MINE", "to": "Mine"})).unwrap(); + app.run("window.toolbar.rename", json!({"name": "Mine", "to": "Pens"})).unwrap(); + assert_eq!(drawer_target(&app.ui), None); + app.run("window.editToolbar", json!({"toolbar": "Pens", "open": true})).unwrap(); + app.run("window.toolbar.delete", json!({"name": "Pens"})).unwrap(); + assert!(!app.ui.tools_drawer && app.ui.drawer_toolbar.is_none()); + } + + /// The Manage Toolbars dialog: the name typed for the selected toolbar renames it at OK, and a + /// name another toolbar has keeps the dialog open. + #[test] + fn the_manage_toolbars_dialog_renames_the_selected_toolbar() { + let mut app = app(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + assert_eq!(app.run("window.toolbar.manage", json!({})).unwrap(), Value::Null); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.kind, MANAGE); + d.fields.insert("selected".into(), json!("Mine")); + d.fields.insert("name".into(), json!("Pens")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(find(&app.ui, "Pens").is_some() && find(&app.ui, "Mine").is_none()); + // A name taken by another toolbar keeps the dialog open. + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + app.run("window.toolbar.manage", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("selected".into(), json!("Pens")); + d.fields.insert("name".into(), json!("other")); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("already")); + assert!(app.ui.dialog.is_some()); + // Drawn with no toolbar selected, the dialog stays open. + let ctx = egui::Context::default(); + app.ui.dialog.as_mut().unwrap().fields.insert("selected".into(), json!("")); + for k in 0..3 { + window_frame(&mut app, &ctx, f64::from(k) * 0.05, 800.0); + } + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == MANAGE)); + } + + /// An open flyout of a group on a toolbar closes when that toolbar is renamed or deleted; the + /// flyout of another toolbar or of the Tools panel stays open. + #[test] + fn renaming_or_deleting_a_toolbar_closes_only_its_own_flyout() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + let mut time = 0.0; + let mut frames = |app: &mut VectorcraftApp| { + for _ in 0..2 { + time += 0.05; + window_frame(app, &ctx, time, 800.0); + } + }; + frames(&mut app); + let open = |app: &mut VectorcraftApp, toolbar: Option<&str>| { + app.ui.flyout = Some(0); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::unique("flyout-tools"), vec!["rectangle".to_string(), "ellipse".to_string()]); + d.insert_temp(crate::toolbar::flyout_toolbar_id(), toolbar.map(String::from)); + }); + }; + for (toolbar, change) in [ + (None, json!({"name": "Other", "to": "Second"})), + (Some("Mine"), json!({"name": "Second", "to": "Third"})), + (Some("Mine"), json!({"name": "Mine", "to": "mine"})), + ] { + open(&mut app, toolbar); + app.run("window.toolbar.rename", change).unwrap(); + frames(&mut app); + assert_eq!(app.ui.flyout, Some(0), "{toolbar:?}'s flyout stays open"); + } + open(&mut app, Some("mine")); + app.run("window.toolbar.rename", json!({"name": "mine", "to": "Pens"})).unwrap(); + frames(&mut app); + assert_eq!(app.ui.flyout, None, "the renamed toolbar's flyout closes"); + open(&mut app, Some("Pens")); + app.run("window.toolbar.delete", json!({"name": "Third"})).unwrap(); + frames(&mut app); + assert_eq!(app.ui.flyout, Some(0), "another toolbar's delete leaves it open"); + app.run("window.toolbar.delete", json!({"name": "Pens"})).unwrap(); + frames(&mut app); + assert_eq!(app.ui.flyout, None, "the deleted toolbar's flyout closes"); + // Another toolbar that takes the old name before the next frame doesn't keep it open. + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + frames(&mut app); + open(&mut app, Some("Mine")); + app.run("window.toolbar.rename", json!({"name": "Mine", "to": "X"})).unwrap(); + app.run("window.toolbar.rename", json!({"name": "Other", "to": "Mine"})).unwrap(); + assert_eq!(app.ui.flyout, None, "renamed, then its name taken"); + open(&mut app, Some("Mine")); + app.run("window.toolbar.delete", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + assert_eq!(app.ui.flyout, None, "deleted, then its name taken"); + } + + /// A toolbar deleted while Manage Toolbars has it selected leaves none selected, and a click + /// on + or the trash can meant for it does nothing, so the dialog can go on. + #[test] + fn manage_toolbars_lets_go_of_a_toolbar_deleted_elsewhere() { + let mut app = app(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + for action in [Some(false), Some(true), None] { + app.run("window.toolbar.manage", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("selected".into(), json!("Mine")); + d.fields.insert("name".into(), json!("Typed")); + app.run("window.toolbar.delete", json!({"name": "Mine"})).unwrap(); + manage_step(&mut app, action.is_none(), None, action); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("selected"), d.str("name")), (String::new(), String::new()), "{action:?}"); + assert_eq!(listed(&app), ["Other"], "{action:?}"); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.delete", json!({"name": "Other"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + } + // OK closes the dialog. + app.ui.dialog.as_mut().unwrap().fields.insert("selected".into(), json!("Gone")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + } + + /// A name that can't be given to the selected toolbar stops a click on +, the trash can or + /// another toolbar, so the name typed stays to be corrected. + #[test] + fn a_refused_name_stops_the_next_click_in_manage_toolbars() { + let mut app = app(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + app.run("window.toolbar.manage", json!({})).unwrap(); + let typed = |app: &mut VectorcraftApp| { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("selected".into(), json!("Mine")); + d.fields.insert("name".into(), json!("other")); + }; + for (pick, action) in [(None, Some(false)), (None, Some(true)), (Some("Other".to_string()), None)] { + typed(&mut app); + manage_step(&mut app, false, pick, action); + assert_eq!(listed(&app), ["Mine", "Other"]); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("selected"), d.str("name")), ("Mine".to_string(), "other".to_string())); + } + // A name that is free renames the toolbar first, then the trash can deletes it. + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Pens")); + manage_step(&mut app, false, None, Some(false)); + assert_eq!(listed(&app), ["Other"]); + } + + /// A new name and a copy's name keep to the length New Toolbar allows, so the toolbar is kept + /// when the preferences load again. + #[test] + fn renamed_and_copied_toolbars_keep_names_short_enough_to_load() { + let mut app = app(); + let long = "x".repeat(MAX_NAME); + app.run("window.toolbar.new", json!({"name": long})).unwrap(); + assert!(app.run("window.toolbar.rename", json!({"name": long, "to": "y".repeat(MAX_NAME + 1)})).unwrap_err().contains("at most")); + let copy = app.run("window.toolbar.duplicate", json!({"name": long})).unwrap(); + let copy = copy["toolbar"].as_str().unwrap().to_string(); + assert!(copy.ends_with(" Copy") && copy.chars().count() <= MAX_NAME, "{copy}"); + let second = app.run("window.toolbar.duplicate", json!({"name": long})).unwrap(); + assert!(second["toolbar"].as_str().unwrap().ends_with(" Copy 2")); + let mut bars = app.ui.custom_toolbars.clone(); + sanitize(&mut bars); + assert_eq!(bars.len(), 3); + } + + /// A copy made while Tab hides the toolbars shows alone, as a new toolbar does. + #[test] + fn a_copy_made_while_tab_hides_the_toolbars_shows_alone() { + let mut app = app(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + app.ui.tab_hid = Some(Default::default()); + app.run("window.toolbar.duplicate", json!({"name": "Mine"})).unwrap(); + assert!(app.ui.tab_hid.is_none()); + let shown: Vec<_> = app.ui.custom_toolbars.iter().filter(|b| b.shown).map(|b| b.name.as_str()).collect(); + assert_eq!(shown, ["Mine Copy"]); + } + /// A hand-edited file: unnamed toolbars and repeated names go, unknown tools go, each tool once, /// and a group shows a tool chosen last only when the group holds it. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d68217e52a..67219de40a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -298,6 +298,7 @@ registry! { Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)), Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)), NewToolbar: [crate::custom_toolbars::KIND] => DialogSpec::window(crate::custom_toolbars::show_dialog, |app, _| crate::custom_toolbars::confirm(app)), + ManageToolbars: [crate::custom_toolbars::MANAGE] => DialogSpec::window(crate::custom_toolbars::show_manage, |app, _| crate::custom_toolbars::confirm_manage(app)), FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)), SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC, Confirm: [confirm::KIND] => confirm::SPEC, @@ -452,7 +453,9 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { /// Set `field` of `d` to `value` for `ui.dialog.set`, as the dialog's own control for it does: a /// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is, -/// and an Edit Data Set field set this way is no longer the row's empty text value. +/// an Edit Data Set field set this way is no longer the row's empty text value, and a Layer +/// Options setting set this way for several rows is sent to every row (Template also sets Lock +/// and Dim Images, as its checkbox does). pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == brush_options::KIND { brush_art_options::note_set(d, &field); @@ -460,6 +463,9 @@ pub fn set_field(d: &mut Dialog, field: String, value: Value) { if d.kind == variables::KIND { variables::note_set(d, &field); } + if d.kind == layer_options::KIND { + layer_options::note_set(d, &field, &value); + } d.fields.insert(field, value); } diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index e24587eae6..58ad3cc9da 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -32,11 +32,11 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.separator(); match tab { 1 => { - ui.set_width(660.0); + ui.set_width(820.0); crate::credits::contributors_ui(app, ui); } 2 => { - ui.set_width(660.0); + ui.set_width(820.0); crate::credits::models_ui(ui); } _ => about_tab(app, ui), diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 85282add14..15d0b7004d 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -43,6 +43,7 @@ fn heading_label(d: &Dialog) -> String { /// Fields: `__effect`, `__label`, `__item?` (as given to `effect.dialog`), `__target` and /// `__index` (the applied effect's item and position). OK edits it; `discard: true` then OK adds /// a new one. +/// `__editTarget` stores the targets used when editing the applied effect. pub const EXISTS: &str = "effectExists"; pub(super) const EXISTS_SPEC: DialogSpec = DialogSpec { @@ -67,7 +68,8 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { // The list the effect goes to: `item` as given, else the active item's (the object's without // one). A new effect keeps `item` unresolved, so `effect.apply` targets each object as usual. let target = p.get("item").cloned().unwrap_or_else(|| json!(app.session.appearance_item())); - let applied = target_effects(app, &target); + let (edit_target, applied) = target_effects(app, p); + let target = edit_target.get("item").cloned().unwrap_or(target); let mut fields = Map::new(); fields.insert("__effect".into(), json!(id)); fields.insert("__label".into(), json!(info.label.trim_end_matches('…'))); @@ -85,12 +87,14 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { return Err(format!("effect {k} is `{}`, not `{id}`", e.id)); } fields.insert("__item".into(), target); + fields.insert("__editTarget".into(), edit_target); fields.insert("__index".into(), json!(k)); fields.extend(params_of(e)); } None => { if let Some(k) = applied.iter().rposition(|e| e.id == id) { fields.insert("__target".into(), target); + fields.insert("__editTarget".into(), edit_target); fields.insert("__index".into(), json!(k)); app.ui.dialog = Some(Dialog { kind: EXISTS.into(), fields }); return Ok(json!({ "pending": EXISTS })); @@ -106,11 +110,14 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(Value::Null) } -/// The function reads effects at `item` (an index or null) from the targeted node or first selected object. -fn target_effects(app: &VectorcraftApp, item: &Value) -> Vec { - let Some(st) = app.session.active() else { return vec![] }; - let node = st.selection.subjects().first().and_then(|id| st.doc.node(*id)); - node.and_then(|n| n.appearance.effects_at(item.as_u64().map(|i| i as usize))).cloned().unwrap_or_default() +/// The function returns target parameters and effects at `item` (an index or null). +/// An omitted `item` uses the active row. +fn target_effects(app: &VectorcraftApp, p: &Value) -> (Value, Vec) { + let mut params = json!({"aligned": p.get("aligned").and_then(Value::as_bool).unwrap_or(false)}); + if let Some(item) = p.get("item") { + params["item"] = item.clone(); + } + app.session.appearance_effect_target(¶ms).map(|(target, effects)| (target, effects.to_vec())).unwrap_or_else(|| (json!({}), vec![])) } /// An applied effect's values over its defaults. @@ -128,6 +135,7 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result let target = fields.remove("__target").unwrap_or(Value::Null); if d.bool("discard") { fields.remove("__index"); + fields.remove("__editTarget"); let id = d.str("__effect"); let defaults = vectorcraft_effects::default_params(&id); fields.extend(defaults.and_then(|v| v.as_object().cloned()).unwrap_or_default()); @@ -136,7 +144,10 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result } } else { let k = fields.get("__index").and_then(Value::as_u64).unwrap_or(0) as usize; - fields.extend(target_effects(app, &target).get(k).map(params_of).unwrap_or_default()); + let edit_target = fields.get("__editTarget").cloned().unwrap_or_else(|| json!({"item": target})); + let applied = app.session.appearance_effect_target(&edit_target).map(|(_, effects)| effects.to_vec()).unwrap_or_default(); + let params = applied.get(k).map(params_of).unwrap_or_default(); + fields.extend(params); fields.insert("__item".into(), target); } fields.insert("preview".into(), json!(true)); @@ -804,7 +815,10 @@ fn command(d: &Dialog) -> (&'static str, Value) { let params = form::params(d); let item = d.fields.get("__item").cloned(); if let Some(index) = d.fields.get("__index") { - let mut p = json!({"index": index, "item": item.unwrap_or(Value::Null), "params": params}); + let mut p = d.fields.get("__editTarget").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({})); + p["index"] = index.clone(); + p["item"] = item.unwrap_or(Value::Null); + p["params"] = params; let id = d.str("__effect"); // The displayed style comes from the first target. Preserve other targets' Warp IDs // unless the user deliberately chooses Style, including after a preview rollback. diff --git a/crates/ui-egui/src/dialogs/layer_options.rs b/crates/ui-egui/src/dialogs/layer_options.rs index d33941dc61..6b3d294089 100644 --- a/crates/ui-egui/src/dialogs/layer_options.rs +++ b/crates/ui-egui/src/dialogs/layer_options.rs @@ -5,7 +5,8 @@ //! Fields: `ids` (the rows edited; none for a new layer), `mode` (`edit`, `new` or `newSublayer`), //! `layer` (the rows are layers), `name`, `color` (a preset's name such as "Light Blue", or //! `#rrggbb`), `template`, `locked`, `visible`, `printable`, `preview`, `dimImages` and -//! `dimPercent`. OK runs `layer.setProps`, `layer.new` or `layer.newSublayer`. +//! `dimPercent`. OK runs `layer.setProps`, `layer.new` or `layer.newSublayer`. For several rows +//! the dialog shows the first row's settings, and OK sends only the ones set in the dialog. use serde_json::{Value, json}; use vectorcraft_doc::{LAYER_COLORS, LayerColor, Node, NodeId, NodeKind}; @@ -18,6 +19,75 @@ use crate::{VectorcraftApp, widgets}; /// The dialog kind of Layer Options. pub const KIND: &str = "layerOptions"; +/// The field listing the fields set in the dialog, by its controls or `ui.dialog.set`. +const SET: &str = "__set"; + +/// The field holding, for several rows, the first row's Template, Lock, Dim Images and its +/// percentage as the dialog opened. +const OPENED: &str = "__opened"; + +/// Records that `field` was set. +fn mark(d: &mut Dialog, field: &str) { + match d.fields.get_mut(SET) { + Some(Value::Array(a)) => { + if !a.iter().any(|k| k == field) { + a.push(json!(field)); + } + } + _ => { + d.fields.insert(SET.into(), json!([field])); + } + } +} + +/// Records that `ui.dialog.set` sets `field` to `value`. Template also sets Lock and Dim Images, +/// as its checkbox does. +pub(super) fn note_set(d: &mut Dialog, field: &str, value: &Value) { + if field.starts_with("__") { + return; + } + match value.as_bool() { + Some(on) if field == "template" && on != d.bool("template") => set_template(d, on), + _ => mark(d, field), + } +} + +/// Turns Template on or off. A template is locked and dims its images, and turning Template off +/// unlocks it and turns Dim Images off. For several rows, OK sends Lock and Dim Images only when +/// they are set in the dialog. `layer.setProps` then locks each row that becomes a template and +/// dims it to 50%, unless a percentage is set in the dialog, so the dialog shows 50%; it unlocks +/// each row that stops being a template and stops dimming it. +/// When Template is set back to the first row's value, Lock and Dim Images that were not set in +/// the dialog show the first row's values again. +fn set_template(d: &mut Dialog, on: bool) { + choose(d, "template", json!(on)); + d.fields.insert("locked".into(), json!(on)); + d.fields.insert("dimImages".into(), json!(on)); + let Some(opened) = d.fields.get(OPENED).cloned() else { + return; + }; + if opened.get("template") == Some(&json!(on)) { + for key in ["locked", "dimImages", "dimPercent"] { + if let Some(v) = opened.get(key).filter(|_| !is_set(d, key)) { + d.fields.insert(key.into(), v.clone()); + } + } + } else if on && !is_set(d, "dimPercent") { + d.fields.insert("dimPercent".into(), json!(50)); + } +} + +/// Whether `field` was set in the dialog. +fn is_set(d: &Dialog, field: &str) -> bool { + d.fields.get(SET).and_then(Value::as_array).is_some_and(|a| a.iter().any(|k| k == field)) +} + +/// Sets `key` to `value` from one of the dialog's controls. +fn choose(d: &mut Dialog, key: &str, value: Value) { + d.fields.insert(key.into(), value); + mark(d, key); +} + pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 320.0, ..DialogSpec::FORM }; fn heading(d: &Dialog) -> String { @@ -73,6 +143,11 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let first = ids.first().and_then(|id| st.doc.node(*id)).ok_or("no such layer or object")?; let xml_id = app.session.prefs.identify_objects_by == "xmlId"; let mut f = fields_of(first, xml_id); + if ids.len() > 1 { + let opened: serde_json::Map<_, _> = + ["template", "locked", "dimImages", "dimPercent"].iter().filter_map(|k| Some((k.to_string(), f.get(*k)?.clone()))).collect(); + f.insert(OPENED.into(), Value::Object(opened)); + } f.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::>())); f.insert("mode".into(), json!("edit")); app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f }); @@ -109,7 +184,7 @@ fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str, enabled: boo let v = d.bool(key); let changed = widgets::check(ui, text, v, enabled); if changed { - d.fields.insert(key.into(), json!(!v)); + choose(d, key, json!(!v)); } changed } @@ -154,7 +229,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let cur = d.str("color"); let shown = LAYER_COLORS.iter().map(|(n, _)| *n).find(|n| n.eq_ignore_ascii_case(&cur)).unwrap_or("Custom"); if let Some((n, _)) = color_dropdown(ui, shown).and_then(|i| LAYER_COLORS.get(i)) { - d.fields.insert("color".into(), json!(n)); + choose(d, "color", json!(n)); } let [r, g, b] = LAYER_COLORS .iter() @@ -169,7 +244,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { .unwrap_or(LAYER_COLORS[0].1); let mut rgb = [r, g, b]; if ui.color_edit_button_srgb(&mut rgb).changed() { - d.fields.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + choose(d, "color", json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); } }); ui.end_row(); @@ -179,11 +254,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if layer { ui.horizontal(|ui| { ui.vertical(|ui| { - // A template is locked and dims its images; turning it off frees both. - if toggle(ui, d, "template", tl!("Template"), true) { - let on = d.bool("template"); - d.fields.insert("locked".into(), json!(on)); - d.fields.insert("dimImages".into(), json!(on)); + let template = d.bool("template"); + if widgets::check(ui, tl!("Template"), template, true) { + set_template(d, !template); } toggle(ui, d, "visible", tl!("Show"), true); toggle(ui, d, "preview", tl!("Preview"), true); @@ -191,8 +264,13 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { toggle(ui, d, "dimImages", tl!("Dim Images to:"), true); let v = d.f64("dimPercent", 50.0); let dim = d.bool("dimImages"); - if let Some(v) = ui.add_enabled_ui(dim, |ui| crate::widgets::range_field(ui, "dimPercent", v, 0.0..=100.0, "%", 0, 52.0)).inner { - d.fields.insert("dimPercent".into(), json!(v)); + if let Some(v) = ui + .add_enabled_ui(dim, |ui| { + crate::widgets::range_field_named(ui, "dimPercent", Some(tl!("Dim Images to:")), v, 0.0..=100.0, "%", 0, 52.0) + }) + .inner + { + choose(d, "dimPercent", json!(v)); } }); }); @@ -210,7 +288,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -/// The command and params OK runs. +/// The command and params OK runs. For several rows only the settings set in the dialog are sent, +/// so each row keeps its own values for the rest. pub(crate) fn params(d: &Dialog) -> (&'static str, Value) { let mut p = serde_json::Map::new(); let many = d.fields.get("ids").and_then(Value::as_array).is_some_and(|a| a.len() > 1); @@ -226,6 +305,12 @@ pub(crate) fn params(d: &Dialog) -> (&'static str, Value) { p.insert("preview".into(), json!(d.bool("preview"))); p.insert("dimImages".into(), if d.bool("dimImages") { json!(d.f64("dimPercent", 50.0).clamp(0.0, 100.0)) } else { json!(false) }); } + if many { + // A percentage set in the dialog is sent whenever Dim Images is on, also when Dim Images + // was turned on by Template. + let percent = d.bool("dimImages") && is_set(d, "dimPercent"); + p.retain(|k, _| is_set(d, k) || (k == "dimImages" && percent)); + } match d.str("mode").as_str() { "new" => ("layer.new", Value::Object(p)), "newSublayer" => ("layer.newSublayer", Value::Object(p)), @@ -259,8 +344,9 @@ mod tests { out.textures_delta.clear(); } + /// Sets a field as `ui.dialog.set` does. fn set(app: &mut VectorcraftApp, k: &str, v: Value) { - app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + super::super::set_field(app.ui.dialog.as_mut().unwrap(), k.into(), v); } /// Explicit object ids must not silently disappear or fall back to the highlighted row. @@ -300,6 +386,204 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); } + /// Two layers with different options, the first plain and the second green, locked, in + /// outline view and dimmed to 30%. + fn two_layers(app: &mut VectorcraftApp) -> (NodeId, NodeId) { + let first = app.session.doc().unwrap().doc.layers[0].id; + let p = json!({"color": "#00ff00", "locked": true, "preview": false, "dimImages": 30}); + let second = NodeId(app.session.execute("layer.new", &p).unwrap()["id"].as_u64().unwrap()); + app.run("ui.layerOptions", json!({"ids": [first.0, second.0]})).unwrap(); + draw(app); + (first, second) + } + + fn layer(app: &VectorcraftApp, id: NodeId) -> Node { + app.session.doc().unwrap().doc.node(id).unwrap().clone() + } + + /// A setting set to the first layer's own value still goes to every layer. + #[test] + fn layer_options_for_several_layers_send_only_the_settings_set() { + let mut app = app(); + let (first, second) = two_layers(&mut app); + set(&mut app, "printable", json!(false)); + set(&mut app, "color", json!("Light Blue")); + let (_, p) = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!(p, json!({"printable": false, "color": "Light Blue", "ids": [first.0, second.0]})); + super::super::confirm(&mut app).unwrap(); + let (a, b) = (layer(&app, first), layer(&app, second)); + assert!(matches!(a.kind, NodeKind::Layer { color: LayerColor::Preset(0), printable: false, preview: true, dim_images: None, .. })); + assert!(!a.locked); + let NodeKind::Layer { color, printable, preview, dim_images, .. } = b.kind else { panic!("not a layer") }; + assert_eq!((color, printable, preview, dim_images, b.locked), (LayerColor::Preset(0), false, false, Some(30), true)); + } + + /// Template locks and dims to 50% each layer whose Template it changes; a Dim Images + /// percentage set as well goes to every layer. + #[test] + fn template_for_several_layers_leaves_lock_and_dimming_to_each_layer() { + let mut app = app(); + let (first, second) = two_layers(&mut app); + app.run("layer.setProps", json!({"ids": [second.0], "template": true, "dimImages": 30})).unwrap(); + app.run("ui.layerOptions", json!({"ids": [first.0, second.0]})).unwrap(); + set(&mut app, "template", json!(true)); + let (_, p) = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!(p, json!({"template": true, "ids": [first.0, second.0]})); + super::super::confirm(&mut app).unwrap(); + let (a, b) = (layer(&app, first), layer(&app, second)); + assert!(a.is_template() && a.locked && matches!(a.kind, NodeKind::Layer { dim_images: Some(50), .. })); + assert!(b.is_template() && b.locked && matches!(b.kind, NodeKind::Layer { dim_images: Some(30), .. })); + // The first layer is dimmed to 50% now; 50% set again goes to both. + app.run("ui.layerOptions", json!({"ids": [first.0, second.0]})).unwrap(); + set(&mut app, "dimPercent", json!(50)); + let (_, p) = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!(p, json!({"dimImages": 50.0, "ids": [first.0, second.0]})); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(layer(&app, second).kind, NodeKind::Layer { dim_images: Some(50), .. })); + // A percentage set while Dim Images is off is sent once Dim Images is on. + app.run("layer.setProps", json!({"ids": [first.0], "template": false, "dimImages": false})).unwrap(); + app.run("ui.layerOptions", json!({"ids": [first.0, second.0]})).unwrap(); + set(&mut app, "dimPercent", json!(20)); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()).1, json!({"ids": [first.0, second.0]})); + set(&mut app, "dimImages", json!(true)); + assert_eq!(params(app.ui.dialog.as_ref().unwrap()).1, json!({"dimImages": 20.0, "ids": [first.0, second.0]})); + } + + /// A percentage set while Dim Images is off is sent when Template turns Dim Images on. + #[test] + fn a_percentage_set_before_template_turns_dimming_on_goes_to_every_layer() { + let mut app = app(); + let (first, second) = two_layers(&mut app); + set(&mut app, "dimPercent", json!(20)); + set(&mut app, "template", json!(true)); + let (_, p) = params(app.ui.dialog.as_ref().unwrap()); + assert_eq!(p, json!({"template": true, "dimImages": 20.0, "ids": [first.0, second.0]})); + super::super::confirm(&mut app).unwrap(); + for id in [first, second] { + assert!(matches!(layer(&app, id).kind, NodeKind::Layer { template: true, dim_images: Some(20), .. })); + } + } + + /// The open dialog of `app` in a test harness, drawn and ready for clicks. + fn harness(app: VectorcraftApp) -> egui_kittest::Harness<'static, VectorcraftApp> { + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + super::super::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + h + } + + /// Clicking Template ticks Lock and Dim Images and leaves them to each layer; clicking Dim + /// Images off afterwards sends that to every layer. + #[test] + fn dim_images_clicked_after_template_goes_to_every_layer() { + use egui_kittest::kittest::Queryable; + let mut app = app(); + let (first, second) = two_layers(&mut app); + let mut h = harness(app); + h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Template").click(); + h.run_steps(3); + let d = h.state().ui.dialog.as_ref().unwrap(); + assert!(d.bool("template") && d.bool("locked") && d.bool("dimImages")); + assert_eq!(params(d).1, json!({"template": true, "ids": [first.0, second.0]})); + h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Dim Images to:").click(); + h.run_steps(3); + let (_, p) = params(h.state().ui.dialog.as_ref().unwrap()); + assert_eq!(p, json!({"template": true, "dimImages": false, "ids": [first.0, second.0]})); + let app = h.state_mut(); + super::super::confirm(app).unwrap(); + for id in [first, second] { + let n = layer(app, id); + assert!(n.locked && matches!(n.kind, NodeKind::Layer { template: true, dim_images: None, .. })); + } + } + + /// Template set through `ui.dialog.set` locks and dims as its checkbox does, and set back it + /// shows the first layer's Lock and Dim Images again and sends neither. + #[test] + fn template_set_and_set_back_leaves_lock_and_dimming_alone() { + let mut app = app(); + let (first, second) = two_layers(&mut app); + app.run("layer.setProps", json!({"ids": [first.0], "locked": true})).unwrap(); + app.run("ui.layerOptions", json!({"ids": [first.0, second.0]})).unwrap(); + set(&mut app, "template", json!(true)); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.bool("locked") && d.bool("dimImages")); + assert_eq!(params(d).1, json!({"template": true, "ids": [first.0, second.0]})); + set(&mut app, "template", json!(false)); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.bool("locked") && !d.bool("dimImages")); + assert_eq!(params(d).1, json!({"template": false, "ids": [first.0, second.0]})); + super::super::confirm(&mut app).unwrap(); + assert!(layer(&app, first).locked && layer(&app, second).locked); + assert!(matches!(layer(&app, second).kind, NodeKind::Layer { dim_images: Some(30), .. })); + } + + /// Template turned on shows the 50% that `layer.setProps` dims each new template to, and + /// turned back off shows the first layer's own percentage again. + #[test] + fn template_shows_the_percentage_it_dims_to() { + let mut app = app(); + let (first, second) = two_layers(&mut app); + app.run("layer.setProps", json!({"ids": [first.0], "dimImages": 30})).unwrap(); + app.run("ui.layerOptions", json!({"ids": [first.0, second.0]})).unwrap(); + set(&mut app, "template", json!(true)); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.f64("dimPercent", 0.0), 50.0); + set(&mut app, "template", json!(false)); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.bool("dimImages")); + assert_eq!(d.f64("dimPercent", 0.0), 30.0); + assert_eq!(params(d).1, json!({"template": false, "ids": [first.0, second.0]})); + set(&mut app, "template", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(layer(&app, first).kind, NodeKind::Layer { template: true, dim_images: Some(50), .. })); + } + + /// Template set through `ui.dialog.set` for one layer locks it and dims its images, as the + /// checkbox does. + #[test] + fn template_set_for_one_layer_locks_and_dims_it() { + let mut app = app(); + let id = app.session.doc().unwrap().doc.layers[0].id; + app.run("ui.layerOptions", json!({"ids": [id.0]})).unwrap(); + set(&mut app, "template", json!(true)); + super::super::confirm(&mut app).unwrap(); + let n = layer(&app, id); + assert!(n.locked && matches!(n.kind, NodeKind::Layer { template: true, dim_images: Some(50), .. })); + } + + /// A percentage typed in while Dim Images is on stays set when Template is then clicked on, + /// since Template leaves Dim Images on. + #[test] + fn a_percentage_typed_before_template_goes_to_every_layer() { + use egui_kittest::kittest::Queryable; + let mut app = app(); + let (first, second) = two_layers(&mut app); + app.run("layer.setProps", json!({"ids": [first.0], "dimImages": 30})).unwrap(); + app.run("ui.layerOptions", json!({"ids": [first.0, second.0]})).unwrap(); + set(&mut app, "dimPercent", json!(40)); + let mut h = harness(app); + h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Template").click(); + h.run_steps(3); + let (_, p) = params(h.state().ui.dialog.as_ref().unwrap()); + assert_eq!(p, json!({"template": true, "dimImages": 40.0, "ids": [first.0, second.0]})); + // Template off again shows the first layer's Dim Images, on, and the percentage still + // goes to every layer. + h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Template").click(); + h.run_steps(3); + let (_, p) = params(h.state().ui.dialog.as_ref().unwrap()); + assert_eq!(p, json!({"template": false, "dimImages": 40.0, "ids": [first.0, second.0]})); + } + #[test] fn new_layer_options_make_a_layer_and_objects_get_name_show_and_lock() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 00811a08bb..3d0cb14421 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -105,6 +105,7 @@ fn label(o: &FormatOption) -> String { "embedProfiles" => tl!("Embed ICC Profiles").into(), "pdfCompatible" => tl!("Create PDF-Compatible File").into(), "compress" => tl!("Use Compression").into(), + "editingData" => tl!("Include Editing Data for Other Apps").into(), _ => form::humanize(o.name), } } diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 6c70eb4491..d1984536fe 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -12,11 +12,20 @@ //! //! With Preferences › User Interface › Open Documents As Tabs cleared, each document opened or //! created afterwards opens in a floating window ([`opened`]). +//! +//! Each view of a document that Window › New Window opened ([`views`]) is a window of its own +//! here: it has its own tab, pane or floating window. use serde_json::{Value, json}; use crate::VectorcraftApp; +mod views; +pub(crate) use views::{ + Views, Win, close_current, close_window, current, label, new_window, retain as retain_views, show_window, unfit_all, view, view_mut, windows, +}; +use views::{find, key, window_param}; + /// How far above or below the tab strip, in points, a dragged document tab must be released to /// float its document. pub(crate) const TEAR_OFF: f32 = 24.0; @@ -30,11 +39,13 @@ const CASCADE_MARGIN: f32 = 24.0; /// The smallest size of a document window, in points. const MIN_SIZE: egui::Vec2 = egui::vec2(240.0, 180.0); -/// A document in a floating window. +/// A document's view in a floating window. #[derive(Clone, Debug)] pub struct DocWindow { /// The document ([`vectorcraft_engine::DocState::uid`]). pub uid: u64, + /// The view's number ([`views`]). + pub view: u32, /// Where the window goes, in the main window's points. pub place: egui::Rect, /// The window's own number ([`Windows::opened`]): a document floated again gets a new window, @@ -61,8 +72,8 @@ pub struct DocWindow { type Fit = (egui::Vec2, f64, vectorcraft_geom::Point); impl DocWindow { - fn new(uid: u64, place: egui::Rect) -> Self { - Self { uid, place, number: 0, generation: 0, screen: None, focused: false, body: None, fit: None, grab: None } + fn new((uid, view): (u64, u32), place: egui::Rect) -> Self { + Self { uid, view, place, number: 0, generation: 0, screen: None, focused: false, body: None, fit: None, grab: None } } } @@ -71,17 +82,19 @@ impl DocWindow { pub struct Windows { /// The document area of the main window (the tabs and the canvas) in its last frame. pub(crate) area: Option, - /// The current document (its uid) when the windows were last drawn: a floating document that - /// becomes the current one comes to the front. - current: Option, - /// While Presentation Mode is on: the floating documents (their uids) it has shown. - presented: Option>, + /// The current window ([`views::key`]) when the windows were last drawn: a floating window + /// that becomes the current one comes to the front. + current: Option<(u64, u32)>, + /// While Presentation Mode is on: the floating windows ([`views::key`]) it has shown. + presented: Option>, /// The main window's tab strip in its last frame, while it was drawn. pub(crate) strip: Option, /// Counts the windows opened ([`DocWindow::number`]). opened: u32, /// The open documents (their uids) when [`opened`] last ran, or when the app started. known: Vec, + /// The documents' views beyond the one each shows ([`views`]). + pub(crate) views: Views, } impl Windows { @@ -97,32 +110,51 @@ pub(crate) fn torn_off(strip: egui::Rect, p: egui::Pos2) -> bool { p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF } -/// Whether document `i` is in a floating window. +/// Whether window `w` is in a floating window. +pub(crate) fn floats(app: &VectorcraftApp, w: Win) -> bool { + key(app, w).is_some_and(|(u, n)| app.ui.floating.iter().any(|x| x.uid == u && x.view == n)) +} + +/// Whether document `i`'s shown view is in a floating window. +#[cfg(test)] pub(crate) fn is_floating(app: &VectorcraftApp, i: usize) -> bool { - app.session.documents().get(i).is_some_and(|d| app.ui.floating.iter().any(|w| w.uid == d.uid)) + floats(app, (i, views::shown(app, i))) } -/// The documents under the tab strip, by index, in tab order. +/// The windows under the tab strip, in tab order. +pub(crate) fn docked_windows(app: &VectorcraftApp) -> Vec { + windows(app).into_iter().filter(|w| !floats(app, *w)).collect() +} + +/// The documents with a window under the tab strip, by index, in tab order. +#[cfg(test)] pub(crate) fn docked(app: &VectorcraftApp) -> Vec { - (0..app.session.documents().len()).filter(|i| !is_floating(app, *i)).collect() + let mut docs: Vec = docked_windows(app).into_iter().map(|(i, _)| i).collect(); + docs.dedup(); + docs } -/// The document the tab strip shows: the current one when it is under the tabs, else the tabbed -/// document that was current last, else the first tabbed one. -pub(crate) fn tab_document(app: &VectorcraftApp) -> Option { - let docked = docked(app); - let active = app.session.active_index(); - if let Some(a) = active.filter(|a| docked.contains(a)) { - return Some(a); +/// The window the tab strip shows: the current one when it is under the tabs, else the tabbed +/// window that was current last, else the first tabbed one. +pub(crate) fn tab_window(app: &VectorcraftApp) -> Option { + let docked = docked_windows(app); + if let Some(c) = current(app).filter(|c| docked.contains(c)) { + return Some(c); } - let last = app.ui.tab_document.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid)); - last.filter(|i| docked.contains(i)).or(docked.first().copied()) + let last = app.ui.tab_document.and_then(|k| find(app, k)); + last.filter(|w| docked.contains(w)).or(docked.first().copied()) } -/// Whether the current document is in a floating window, outside the Home screen and Presentation -/// Mode: the main window's canvas then draws the tab strip's document as a pane. +/// The document of the window the tab strip shows ([`tab_window`]). +#[cfg(test)] +pub(crate) fn tab_document(app: &VectorcraftApp) -> Option { + tab_window(app).map(|(i, _)| i) +} + +/// Whether the current window is a floating one, outside the Home screen and Presentation Mode: +/// the main window's canvas then draws the tab strip's window as a pane. pub(crate) fn current_floats(app: &VectorcraftApp) -> bool { - app.session.active_index().is_some_and(|i| is_floating(app, i)) && !crate::menus::home_showing(app) && !presenting(app) + current(app).is_some_and(|w| floats(app, w)) && !crate::menus::home_showing(app) && !presenting(app) } /// Whether Presentation Mode is on: the main window shows the current document, and the floating @@ -151,11 +183,11 @@ fn area(app: &VectorcraftApp) -> egui::Rect { app.canvas.windows.area.filter(|r| r.is_positive()).unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))) } -/// Float documents `docs` (indices), each at the next cascade place after the windows already -/// open. Each fits its window. -fn float(app: &mut VectorcraftApp, docs: &[usize]) { +/// Float windows `wins`, each at the next cascade place after the windows already open. Each fits +/// its window. +fn float(app: &mut VectorcraftApp, wins: &[Win]) { app.sync_views(); - place(app, docs); + place(app, wins); } /// The documents opened or created since the last call: each one opened while Preferences › User @@ -168,131 +200,120 @@ pub(crate) fn opened(app: &mut VectorcraftApp) { if known.len() == docs.len() && known.iter().zip(docs).all(|(u, d)| *u == d.uid) { return; } - let new: Vec = docs.iter().enumerate().filter(|(_, d)| !d.opened_as_tab && !known.contains(&d.uid)).map(|(i, _)| i).collect(); + let new: Vec = docs.iter().enumerate().filter(|(_, d)| !d.opened_as_tab && !known.contains(&d.uid)).map(|(i, _)| (i, 1)).collect(); app.canvas.windows.known = docs.iter().map(|d| d.uid).collect(); if !new.is_empty() { place(app, &new); } } -/// [`float`] for documents whose views exist. -fn place(app: &mut VectorcraftApp, docs: &[usize]) { +/// [`float`] for windows whose views exist. +fn place(app: &mut VectorcraftApp, wins: &[Win]) { let first = app.ui.floating.len(); - let places = cascade_places(area(app), first + docs.len()); - for (k, i) in docs.iter().enumerate() { - let Some(uid) = app.session.documents().get(*i).map(|d| d.uid) else { continue }; - if app.ui.floating.iter().any(|w| w.uid == uid) { + let places = cascade_places(area(app), first + wins.len()); + for (k, w) in wins.iter().enumerate() { + let Some(key) = key(app, *w) else { continue }; + if floats(app, *w) { continue; } let place = places.get(first + k).copied().unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, MIN_SIZE)); app.canvas.windows.opened = app.canvas.windows.opened.wrapping_add(1); - let mut w = DocWindow::new(uid, place); - w.number = app.canvas.windows.opened; - app.ui.floating.push(w); - if let Some(v) = app.views.get_mut(*i) { + let mut win = DocWindow::new(key, place); + win.number = app.canvas.windows.opened; + app.ui.floating.push(win); + if let Some(v) = view_mut(app, *w) { v.fitted = false; } } - if !docs.is_empty() { + if !wins.is_empty() { app.ui.home = None; } } -/// The floating documents' indices, in the order their windows opened. +/// The floating windows' document indices, in the order the windows opened. fn floating_indices(app: &VectorcraftApp) -> Vec { - app.ui.floating.iter().filter_map(|w| app.session.documents().iter().position(|d| d.uid == w.uid)).collect() + floating_windows(app).into_iter().map(|(i, _)| i).collect() } -/// The open document `p`'s `index` names: `Ok(None)` without one. -fn document_param(app: &VectorcraftApp, p: &Value) -> Result, String> { - match p.get("index") { - None | Some(Value::Null) => Ok(None), - Some(v) => v - .as_u64() - .and_then(|i| usize::try_from(i).ok()) - .filter(|i| *i < app.session.documents().len()) - .map(Some) - .ok_or_else(|| "no such document".to_string()), - } +/// The floating windows, in the order they opened. +pub(crate) fn floating_windows(app: &VectorcraftApp) -> Vec { + app.ui.floating.iter().filter_map(|w| find(app, (w.uid, w.view))).collect() } -/// `window.arrange.floatInWindow`: the current document, or document `index`, leaves the tab strip -/// for a floating window of its own, with its top-left corner at `at` (`[x, y]` in the main -/// window's points) when given, else at the next cascade place. A document already floating stays -/// where it is. Document `index` becomes the current one. Returns `{floating}`. +/// `window.arrange.floatInWindow`: the current window, or document `index`'s view `view` (its +/// shown view without one), leaves the tab strip for a floating window of its own, with its +/// top-left corner at `at` (`[x, y]` in the main window's points) when given, else at the next +/// cascade place. A window already floating stays where it is. The window `index` names becomes +/// the current one. Returns `{floating}`, the floating windows' document indices. pub(crate) fn float_in_window(app: &mut VectorcraftApp, p: &Value) -> Result { - let index = document_param(app, p)?; + let named = window_param(app, p)?; let at = p .get("at") .and_then(Value::as_array) .and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))) .filter(|at| at.is_finite()); - if let Some(i) = index.or(app.session.active_index()).filter(|i| !is_floating(app, *i)) { - float(app, &[i]); + if let Some(w) = named.or(current(app)).filter(|w| !floats(app, *w)) { + float(app, &[w]); if let (Some(at), Some(w)) = (at, app.ui.floating.last_mut()) { w.place = egui::Rect::from_min_size(at, w.place.size()); } } - if let Some(i) = index { - crate::chrome::show_document(app, i); + if let Some(w) = named { + show_window(app, w); } Ok(json!({ "floating": floating_indices(app) })) } -/// `window.arrange.consolidateAllWindows` with an `index`: document `index`, floating or not, -/// goes back under its tab, fitted there, and becomes the current one; the other windows and Tile -/// stay as they are. Returns `{tiled, floating}`. +/// `window.arrange.consolidateAllWindows` with an `index`: document `index`'s view `view` (its +/// shown view without one), floating or not, goes back under its tab, fitted there, and becomes +/// the current window; the other windows and Tile stay as they are. Returns `{tiled, floating}`. pub(crate) fn dock(app: &mut VectorcraftApp, p: &Value) -> Result { - let Some(i) = document_param(app, p)? else { + let Some(w) = window_param(app, p)? else { return Err("no such document".into()); }; - if is_floating(app, i) { - let uid = app.session.documents().get(i).map(|d| d.uid); - app.ui.floating.retain(|w| Some(w.uid) != uid); - if let Some(v) = app.views.get_mut(i) { + if let Some((u, n)) = key(app, w).filter(|_| floats(app, w)) { + app.ui.floating.retain(|x| !(x.uid == u && x.view == n)); + if let Some(v) = view_mut(app, w) { v.fitted = false; } } - crate::chrome::show_document(app, i); + show_window(app, w); Ok(json!({ "tiled": app.ui.tiled, "floating": floating_indices(app) })) } -/// `window.arrange.floatAllInWindows`: every document under the tab strip goes to a floating window +/// `window.arrange.floatAllInWindows`: every window under the tab strip goes to a floating window /// of its own. Returns `{floating}`. pub(crate) fn float_all(app: &mut VectorcraftApp) -> Result { - let docked = docked(app); + let docked = docked_windows(app); float(app, &docked); Ok(json!({ "floating": floating_indices(app) })) } -/// `window.arrange.cascade`: every document in a floating window, the windows stacked from the -/// document area's upper left to its lower right in tab order, each fitted to its window, the -/// current document's in front. Returns `{floating}`. +/// `window.arrange.cascade`: every window floating, stacked from the document area's upper left to +/// its lower right in tab order, each fitted to its window, the current window in front. Returns +/// `{floating}`. pub(crate) fn cascade(app: &mut VectorcraftApp) -> Result { app.ui.tiled = false; - let docked = docked(app); + let docked = docked_windows(app); float(app, &docked); - let order: Vec = app.session.documents().iter().map(|d| d.uid).collect(); - app.ui.floating.sort_by_key(|w| order.iter().position(|u| *u == w.uid)); + let order: Vec<(u64, u32)> = windows(app).into_iter().filter_map(|w| key(app, w)).collect(); + app.ui.floating.sort_by_key(|w| order.iter().position(|k| *k == (w.uid, w.view))); let places = cascade_places(area(app), app.ui.floating.len()); for (w, place) in app.ui.floating.iter_mut().zip(places) { w.place = place; w.generation += 1; } - for v in &mut app.views { - v.fitted = false; - } - // The current document's window comes to the front. + unfit_all(app); + // The current window comes to the front. app.canvas.windows.current = None; Ok(json!({ "floating": floating_indices(app) })) } -/// Every floating document back under the tab strip ([`crate::canvas::consolidate`], Tile), each +/// Every floating window back under the tab strip ([`crate::canvas::consolidate`], Tile), each /// fitted to where it shows. pub(crate) fn dock_all(app: &mut VectorcraftApp) { - let floating = floating_indices(app); - for i in floating { - if let Some(v) = app.views.get_mut(i) { + for w in floating_windows(app) { + if let Some(v) = view_mut(app, w) { v.fitted = false; } } @@ -319,9 +340,9 @@ fn native(app: &VectorcraftApp, ctx: &egui::Context) -> bool { true } -/// The OS window of document window `uid`. -fn viewport_id(uid: u64) -> egui::ViewportId { - egui::ViewportId::from_hash_of(("vectorcraft-document-window", uid)) +/// The OS window of document window `w`. +fn viewport_id(w: &DocWindow) -> egui::ViewportId { + egui::ViewportId::from_hash_of(("vectorcraft-document-window", w.uid, w.number)) } /// The id of document window `w` inside the main window: a new one for each window and each @@ -352,33 +373,36 @@ struct Shown { /// Draw the floating document windows, after the main window's canvas. Call once per frame from /// the main window. pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { - let live: Vec = app.session.documents().iter().map(|d| d.uid).collect(); - app.ui.floating.retain(|w| live.contains(&w.uid)); - let current = app.session.active().map(|d| d.uid); - if let Some(uid) = current.filter(|u| !app.ui.floating.iter().any(|w| w.uid == *u)) { - app.ui.tab_document = Some(uid); + let live: Vec<(u64, u32)> = windows(app).into_iter().filter_map(|w| key(app, w)).collect(); + app.ui.floating.retain(|w| live.contains(&(w.uid, w.view))); + let current = current(app).and_then(|w| key(app, w)); + let floating = |app: &VectorcraftApp, k: &(u64, u32)| app.ui.floating.iter().any(|w| (w.uid, w.view) == *k); + if let Some(k) = current.filter(|k| !floating(app, k)) { + app.ui.tab_document = Some(k); } - // Presentation Mode fits each floating document it shows to the main window: back in their - // floating windows, those documents fit their windows again. + // Presentation Mode fits each floating window it shows to the main window: back in their + // floating windows, those views fit their windows again. let presenting = presenting(app); if presenting { + let is_floating = current.is_some_and(|k| floating(app, &k)); let shown = app.canvas.windows.presented.get_or_insert_default(); - if let Some(uid) = current.filter(|u| app.ui.floating.iter().any(|w| w.uid == *u) && !shown.contains(u)) { - shown.push(uid); + if let Some(k) = current.filter(|k| is_floating && !shown.contains(k)) { + shown.push(k); if let Some(v) = app.session.active_index().and_then(|i| app.views.get_mut(i)) { v.fitted = false; ctx.request_repaint(); } } } else if let Some(shown) = app.canvas.windows.presented.take() { - for (d, v) in app.session.documents().iter().zip(app.views.iter_mut()) { - if shown.contains(&d.uid) { + let wins: Vec = shown.into_iter().filter_map(|k| find(app, k)).collect(); + for w in wins { + if let Some(v) = view_mut(app, w) { v.fitted = false; } } } - // While Presentation Mode hides the windows, `current` keeps the document whose window was in - // front, so a document made current meanwhile comes to the front when it ends. + // While Presentation Mode hides the windows, `current` keeps the window that was in front, so a + // window made current meanwhile comes to the front when it ends. if presenting { return; } @@ -402,7 +426,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } #[cfg(not(target_arch = "wasm32"))] if native { - input::register(ctx, app.services.native_input_hook.clone(), app.ui.floating.iter().map(|w| viewport_id(w.uid)).collect()); + input::register(ctx, app.services.native_input_hook.clone(), app.ui.floating.iter().map(viewport_id).collect()); } // While a dialog or other modal is open the windows take no input, as the main window behind // it takes none: each draws its document as a pane, the current one included. @@ -412,31 +436,29 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut dock = None; let mut over_strip = false; for w in app.ui.floating.clone() { - let Some(i) = app.session.documents().iter().position(|d| d.uid == w.uid) else { continue }; - let shown = if native { - os_window(app, ctx, &w, (i, blocked), raise == Some(w.uid)) - } else { - app_window(app, ctx, &w, (i, blocked), raise == Some(w.uid)) - }; + let Some(win) = find(app, (w.uid, w.view)) else { continue }; + let raised = raise == Some((w.uid, w.view)); + let shown = if native { os_window(app, ctx, &w, (win, blocked), raised) } else { app_window(app, ctx, &w, (win, blocked), raised) }; if blocked { continue; } - if let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid) { - // The OS window took the keyboard: its document becomes the current one. - if shown.focused && !win.focused && app.session.active_index() != Some(i) { - activate = Some(i); + let is_current = views::current(app) == Some(win); + if let Some(x) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid && x.view == w.view) { + // The OS window took the keyboard: its view becomes the current window. + if shown.focused && !x.focused && !is_current { + activate = Some(win); } - win.focused = shown.focused; - win.body = shown.body; - win.fit = shown.fit; + x.focused = shown.focused; + x.body = shown.body; + x.fit = shown.fit; } over_strip |= shown.over_strip; if shown.close { - close = Some(i); + close = Some(win); } else if shown.dock { - dock = Some(i); - } else if shown.activate && app.session.active_index() != Some(i) { - activate = Some(i); + dock = Some(win); + } else if shown.activate && !is_current { + activate = Some(win); } } // The tab strip a dragged window would go back under shows where it lands. @@ -446,61 +468,61 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { painter.rect_filled(strip, 0.0, t.accent.gamma_multiply(0.12)); painter.rect_stroke(strip, 0.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } - if let Some(i) = close { + if let Some(win) = close { // The save prompt opens in the main window. - if let Err(e) = crate::unsaved::close(app, i) { + if let Err(e) = close_window(app, win) { app.status(e); } if native && app.ui.dialog.is_some() { ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); } - } else if let Some(i) = dock { - if let Err(e) = app.run("window.arrange.consolidateAllWindows", json!({ "index": i })) { + } else if let Some((i, n)) = dock { + if let Err(e) = app.run("window.arrange.consolidateAllWindows", json!({ "index": i, "view": n })) { app.status(e); } if native { ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); } - } else if let Some(i) = activate { - crate::chrome::show_document(app, i); - app.canvas.windows.current = app.session.active().map(|d| d.uid); + } else if let Some(win) = activate { + show_window(app, win); + app.canvas.windows.current = views::current(app).and_then(|w| key(app, w)); } } -/// Document `i`'s window body in `ui`: the canvas for the current document, else (and while -/// `blocked` by a modal) a pane. The document fits the body again when the body changed size since -/// `fit` and its view has not moved. → whether a press in the pane asks for its document to become -/// the current one, the body's rect and the fit after this frame. -fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, (i, blocked): (usize, bool), fit: Option) -> (bool, egui::Rect, Option) { +/// Window `win`'s body in `ui`: the canvas for the current window, else (and while `blocked` by a +/// modal) a pane. The view fits the body again when the body changed size since `fit` and the view +/// has not moved. → whether a press in the pane asks for its window to become the current one, the +/// body's rect and the fit after this frame. +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, (win, blocked): (Win, bool), fit: Option) -> (bool, egui::Rect, Option) { let t = crate::theme::Tokens::get(ui.ctx()); let rect = ui.available_rect_before_wrap(); ui.painter().rect_filled(rect, 0.0, t.pasteboard); - if let (Some(v), Some((size, zoom, center))) = (app.views.get_mut(i), fit) + if let (Some(v), Some((size, zoom, center))) = (view_mut(app, win), fit) && size != rect.size() && zoom == v.zoom && center == v.center { v.fitted = false; } - let was_fitted = app.views.get(i).is_some_and(|v| v.fitted); - let pressed = if !blocked && app.session.active_index() == Some(i) && !crate::menus::home_showing(app) { + let was_fitted = view(app, win).is_some_and(|v| v.fitted); + let pressed = if !blocked && views::current(app) == Some(win) && !crate::menus::home_showing(app) { crate::canvas::show(app, ui); false } else { - crate::canvas::pane(app, ui, i, rect) + crate::canvas::pane(app, ui, win, rect) }; ui.advance_cursor_after_rect(rect); - let fit = match app.views.get(i) { + let fit = match view(app, win) { Some(v) if v.fitted && !was_fitted => Some((rect.size(), v.zoom, v.center)), _ => fit, }; (pressed, rect, fit) } -/// Document `i`'s window `w` as an OS window of its own, `blocked` by a modal or not; `raise` -/// brings it to the front. The document's tab is at the top of the window, above its body. -fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown { - let vid = viewport_id(w.uid); +/// Window `win`'s floating window `w` as an OS window of its own, `blocked` by a modal or not; +/// `raise` brings it to the front. Its tab is at the top of the window, above its body. +fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (win, blocked): (Win, bool), raise: bool) -> Shown { + let vid = viewport_id(w); let origin = ctx.input(|inp| inp.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(egui::Pos2::ZERO); let screen = match w.screen { Some((pos, generation)) if generation == w.generation => pos, @@ -510,8 +532,8 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::OuterPosition(pos)); ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::InnerSize(w.place.size())); } - if let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid) { - win.screen = Some((pos, w.generation)); + if let Some(x) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid && x.view == w.view) { + x.screen = Some((pos, w.generation)); } pos } @@ -519,7 +541,7 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b if raise { ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::Focus); } - let title = crate::chrome::document_window_title(app, i).unwrap_or_default(); + let title = crate::chrome::document_window_title(app, win).unwrap_or_default(); let builder = egui::ViewportBuilder::default().with_title(title).with_position(screen).with_inner_size(w.place.size()).with_min_inner_size(MIN_SIZE); let mut forwarded = (Vec::new(), None); @@ -532,9 +554,9 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b // or a modal can keep the document open. ui.ctx().send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let tab = crate::chrome::window_tab(app, ui, i); - let (over_strip, dock) = if blocked { (false, false) } else { drag_tab(app, ui, (w.uid, origin), &tab, strip) }; - let (pressed, rect, fit) = body(app, ui, (i, blocked), w.fit); + let tab = crate::chrome::window_tab(app, ui, win); + let (over_strip, dock) = if blocked { (false, false) } else { drag_tab(app, ui, ((w.uid, w.view), origin), &tab, strip) }; + let (pressed, rect, fit) = body(app, ui, (win, blocked), w.fit); let activate = pressed || (!blocked && (tab.clicked() || tab.drag_started())); // Keys the window's widgets left over run the main window's shortcuts and tool keys, as a // floating panel's do. @@ -559,18 +581,18 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b shown } -/// A drag of document window `uid`'s OS window by the `tab` at its top: the window follows the -/// pointer, and its document goes back under the tabs when the tab is released over the main -/// window's tab `strip` (on the screen). `origin` is the main window's top-left corner on the +/// A drag of floating window `key`'s ([`views::key`]) OS window by the `tab` at its top: the window +/// follows the pointer, and its view goes back under the tabs when the tab is released over the +/// main window's tab `strip` (on the screen). `origin` is the main window's top-left corner on the /// screen. → whether the pointer is over the strip, and whether the tab was released there. fn drag_tab( app: &mut VectorcraftApp, ui: &egui::Ui, - (uid, origin): (u64, egui::Pos2), + ((uid, view), origin): ((u64, u32), egui::Pos2), tab: &egui::Response, strip: Option, ) -> (bool, bool) { - let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == uid) else { + let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == uid && x.view == view) else { return (false, false); }; if !tab.dragged() && !tab.drag_stopped() { @@ -598,14 +620,14 @@ fn drag_tab( (over, false) } -/// Document `i`'s window `w` as a window inside the main window, `blocked` by a modal or not; -/// `raise` brings it to the front. -fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown { +/// Window `win`'s floating window `w` as a window inside the main window, `blocked` by a modal or +/// not; `raise` brings it to the front. +fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (win, blocked): (Win, bool), raise: bool) -> Shown { let id = window_id(w); if raise { ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); } - let title = crate::chrome::document_window_title(app, i).unwrap_or_default(); + let title = crate::chrome::document_window_title(app, win).unwrap_or_default(); let mut open = true; let (mut activate, mut rect, mut fit) = (false, None, w.fit); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(0.0); @@ -619,7 +641,7 @@ fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, .frame(frame) .open(&mut open) .show(ctx, |ui| { - let (pressed, r, f) = body(app, ui, (i, blocked), fit); + let (pressed, r, f) = body(app, ui, (win, blocked), fit); (activate, rect, fit) = (pressed, Some(r), f); }); // A press on its title bar or edges, which brings the window to the front, also makes its diff --git a/crates/ui-egui/src/doc_windows/tests.rs b/crates/ui-egui/src/doc_windows/tests.rs index f32e0b2ab6..4311d8bbe8 100644 --- a/crates/ui-egui/src/doc_windows/tests.rs +++ b/crates/ui-egui/src/doc_windows/tests.rs @@ -257,11 +257,11 @@ fn tile_brings_floating_documents_back_into_the_grid() { app.run("window.arrange.tile", json!({})).unwrap(); app.run("window.arrange.floatInWindow", json!({})).unwrap(); frame(&mut app, &ctx, vec![]); - let docs: Vec = app.canvas.panes.last.iter().map(|(i, _)| *i).collect(); + let docs: Vec = app.canvas.panes.last.iter().map(|(w, _)| w.0).collect(); assert_eq!(docs, [0, 1], "a floating document leaves the grid; the tabbed ones share it"); assert_eq!(app.run("window.arrange.tile", json!({})).unwrap(), json!({"tiled": true, "panes": 3})); frame(&mut app, &ctx, vec![]); - let docs: Vec = app.canvas.panes.last.iter().map(|(i, _)| *i).collect(); + let docs: Vec = app.canvas.panes.last.iter().map(|(w, _)| w.0).collect(); assert_eq!(docs, [0, 1, 2]); assert!(app.ui.floating.is_empty()); } @@ -437,7 +437,7 @@ fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() { app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); app.run("window.arrange.floatInWindow", json!({})).unwrap(); frame(&mut app, &ctx, vec![]); - let vid = viewport_id(app.session.documents()[1].uid); + let vid = viewport_id(&app.ui.floating[0]); host.borrow_mut().close = true; frame(&mut app, &ctx, vec![]); host.borrow_mut().close = false; @@ -725,3 +725,168 @@ fn the_arrange_items_are_enabled_as_the_documents_allow() { app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(); assert_eq!(states(&app), [true, true, true, true, false], "consolidated"); } + +/// The title of window `w`'s tab. +fn title_of(app: &VectorcraftApp, w: Win) -> String { + crate::chrome::document_window_title(app, w).unwrap() +} + +#[test] +fn new_window_opens_a_numbered_view_with_its_own_zoom() { + let mut app = app_with(1); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let name = app.session.documents()[0].title(); + assert!(!title_of(&app, (0, 1)).contains(&format!("{name}:")), "one view: no number"); + let zoom = app.view().unwrap().zoom; + assert_eq!(app.run("window.newWindow", json!({})).unwrap(), json!({"index": 0, "view": 2})); + assert_eq!(windows(&app), [(0, 1), (0, 2)]); + assert_eq!(current(&app), Some((0, 2)), "the new view is the current window"); + assert_eq!(app.view().unwrap().zoom, zoom, "it opens where the first view is"); + assert!(title_of(&app, (0, 1)).starts_with(&format!("{name}:1 @")), "{}", title_of(&app, (0, 1))); + assert!(title_of(&app, (0, 2)).starts_with(&format!("{name}:2 @")), "{}", title_of(&app, (0, 2))); + let shapes = frame(&mut app, &ctx, vec![]); + assert!(tab_drawn(&shapes, &format!("{name}:1")) && tab_drawn(&shapes, &format!("{name}:2")), "each view has a tab"); + // Each view keeps its own zoom. + app.run("view.zoomIn", json!({})).unwrap(); + let zoomed = app.view().unwrap().zoom; + assert_ne!(zoomed, zoom); + show_window(&mut app, (0, 1)); + assert_eq!(app.view().unwrap().zoom, zoom, "the first view did not zoom"); + assert_eq!(view(&app, (0, 2)).unwrap().zoom, zoomed); + show_window(&mut app, (0, 2)); + assert_eq!(app.view().unwrap().zoom, zoomed); + assert_eq!(app.run("window.newWindow", json!({})).unwrap(), json!({"index": 0, "view": 3})); +} + +#[test] +fn an_edit_in_one_view_shows_in_the_other() { + let mut app = app_with(1); + let ctx = egui::Context::default(); + app.run("window.newWindow", json!({})).unwrap(); + app.run("window.arrange.tile", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.canvas.panes.last.iter().map(|(w, _)| *w).collect::>(), [(0, 1), (0, 2)], "one pane per view"); + view_mut(&mut app, (0, 1)).unwrap().zoom = 0.5; + app.view_mut().unwrap().zoom = 2.0; + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let d = &app.session.documents()[0]; + let shown = app.canvas.panes.shown.get(&(d.uid, 1)).map(|(k, _)| (k.revision, k.zoom)); + assert_eq!(shown, Some((d.revision, 0.5)), "the other view's pane shows the edit at its own zoom"); + assert_eq!(app.view().unwrap().zoom, 2.0, "the view edited in keeps its zoom"); +} + +#[test] +fn closing_a_view_leaves_the_document_open() { + let mut app = app_with(1); + let ctx = egui::Context::default(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + app.run("window.newWindow", json!({})).unwrap(); + app.run("window.newWindow", json!({})).unwrap(); + show_window(&mut app, (0, 2)); + assert_eq!(app.run("file.close", json!({})).unwrap(), json!({"index": 0, "views": 2})); + assert!(app.ui.dialog.is_none(), "closing a view does not ask to save"); + assert_eq!((app.session.documents().len(), windows(&app)), (1, vec![(0, 1), (0, 3)])); + assert_eq!(current(&app), Some((0, 3)), "the next view becomes the current window"); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.run("file.close", json!({})).unwrap(), json!({"index": 0, "views": 1})); + assert_eq!(windows(&app), [(0, 1)], "the view left is the document's only view again"); + let name = app.session.documents()[0].title(); + assert!(!title_of(&app, (0, 1)).contains(&format!("{name}:")), "{}", title_of(&app, (0, 1))); + // The last view: the document closes, asking to save it first. + app.run("file.close", json!({})).unwrap(); + assert!(app.ui.dialog.is_some(), "the changed document asks to be saved"); + assert_eq!(app.session.documents().len(), 1); +} + +#[test] +fn arrange_treats_each_view_as_a_window() { + let mut app = app_with(1); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + assert!(!enabled(&app, "window.arrange.tile") && !enabled(&app, "window.arrange.cascade"), "one window"); + app.run("window.newWindow", json!({})).unwrap(); + assert!(enabled(&app, "window.arrange.tile") && enabled(&app, "window.arrange.cascade"), "two views of one document"); + assert_eq!(app.run("window.arrange.tile", json!({})).unwrap(), json!({"tiled": true, "panes": 2})); + app.run("window.arrange.cascade", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + let uid = app.session.documents()[0].uid; + let floating: Vec<(u64, u32)> = app.ui.floating.iter().map(|w| (w.uid, w.view)).collect(); + assert_eq!(floating, [(uid, 1), (uid, 2)], "each view in a window of its own, in tab order"); + assert_ne!(app.ui.floating[0].place.min, app.ui.floating[1].place.min, "stacked down and to the right"); + assert!(docked_windows(&app).is_empty()); + app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(); + assert!(app.ui.floating.is_empty()); + assert_eq!(docked_windows(&app), [(0, 1), (0, 2)]); + // Float in Window floats the current view only. + app.run("window.arrange.floatInWindow", json!({})).unwrap(); + assert_eq!(app.ui.floating.iter().map(|w| (w.uid, w.view)).collect::>(), [(uid, 2)]); + assert_eq!(docked_windows(&app), [(0, 1)], "the first view keeps its tab"); + frame(&mut app, &ctx, vec![]); + let shapes = frame(&mut app, &ctx, vec![]); + let name = app.session.documents()[0].title(); + assert!(tab_drawn(&shapes, &format!("{name}:1")) && !tab_drawn(&shapes, &format!("{name}:2"))); + // A new view of a floating view floats too. + app.run("window.newWindow", json!({})).unwrap(); + assert!(floats(&app, (0, 3)), "it opens beside the view it came from"); + app.run("window.arrange.floatAllInWindows", json!({})).unwrap(); + assert!(docked_windows(&app).is_empty()); + assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({"index": 0, "view": 3})).unwrap()["floating"], json!([0, 0])); + assert_eq!((docked_windows(&app), current(&app)), (vec![(0, 3)], Some((0, 3)))); +} + +#[test] +fn each_pane_draws_its_own_views_outline_or_preview() { + let mut app = app_with(1); + let ctx = egui::Context::default(); + app.run("window.newWindow", json!({})).unwrap(); + show_window(&mut app, (0, 1)); + app.run("view.outline", json!({})).unwrap(); + show_window(&mut app, (0, 2)); + assert!(!app.outline_view(), "the second view is in Preview"); + app.run("window.arrange.tile", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + let uid = app.session.documents()[0].uid; + assert_eq!(app.canvas.panes.shown.get(&(uid, 1)).map(|(k, _)| k.outline), Some(true), "the first view's pane is in Outline"); +} + +#[test] +fn next_and_previous_document_step_through_the_views() { + let mut app = app_with(2); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run("window.newWindow", json!({})).unwrap(); + assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 1})); + assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 0, "view": 1})); + assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 0, "view": 2})); + assert_eq!(current(&app), Some((0, 2))); + assert_eq!(app.run("window.previousDocument", json!({})).unwrap(), json!({"index": 0, "view": 1})); + assert_eq!(app.run("window.previousDocument", json!({})).unwrap(), json!({"index": 1})); +} + +#[test] +fn switching_views_resets_the_tool() { + let mut app = app_with(2); + app.run("window.newWindow", json!({})).unwrap(); + let before = app.session.tool_resets(); + show_window(&mut app, (1, 1)); + assert_eq!(app.session.tool_resets(), before + 1, "another view of the current document resets the tool"); + show_window(&mut app, (1, 1)); + assert_eq!(app.session.tool_resets(), before + 1, "the current view stays as it is"); +} + +#[test] +fn switching_views_targets_the_artboard_the_view_shows() { + let mut app = app_with(1); + app.run("artboard.new", json!({})).unwrap(); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + crate::panels::artboards::select(&mut app, 0); + app.run("window.newWindow", json!({})).unwrap(); + crate::panels::artboards::select(&mut app, 1); + assert_eq!(app.session.tool_options()["active"], json!(1)); + show_window(&mut app, (0, 1)); + assert_eq!(app.session.tool_options()["active"], json!(0), "the first view shows the first artboard"); + show_window(&mut app, (0, 2)); + assert_eq!(app.session.tool_options()["active"], json!(1)); +} diff --git a/crates/ui-egui/src/doc_windows/views.rs b/crates/ui-egui/src/doc_windows/views.rs new file mode 100644 index 0000000000..dda76952ee --- /dev/null +++ b/crates/ui-egui/src/doc_windows/views.rs @@ -0,0 +1,247 @@ +//! Window › New Window: more views of an open document, each with its own zoom, center, rotation, +//! artboard and Outline or Preview, shown as tabs titled with the view's number ("Name:2"). Tile, +//! Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as +//! they arrange a document. Every view shows the same document, so an edit made in one shows in +//! all of them. Closing a view leaves the document open until its last view closes. +//! +//! A window is a document's index and its view's number ([`Win`]). The view a document shows +//! while it is the current one is in [`VectorcraftApp::views`]; its other views are kept in +//! [`Views`]. + +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::state::View; + +/// A window: an open document's index and its view's number (1 for a document's first view). +pub(crate) type Win = (usize, u32); + +/// A view of an open document other than the one [`VectorcraftApp::views`] holds for it. +#[derive(Clone, Debug)] +struct OtherView { + /// The document ([`vectorcraft_engine::DocState::uid`]). + uid: u64, + /// The view's number. + number: u32, + view: View, +} + +/// The views of the open documents beyond the one each document shows. +#[derive(Default)] +pub struct Views { + others: Vec, + /// By document uid: the number of the view in [`VectorcraftApp::views`] (1 when absent). + shown: HashMap, +} + +fn uid(app: &VectorcraftApp, i: usize) -> Option { + app.session.documents().get(i).map(|d| d.uid) +} + +/// The number of the view of document `i` that [`VectorcraftApp::views`] holds. +pub(crate) fn shown(app: &VectorcraftApp, i: usize) -> u32 { + uid(app, i).and_then(|u| app.canvas.windows.views.shown.get(&u).copied()).unwrap_or(1) +} + +/// The numbers of document `i`'s views, in order; none for a document that is not open. +pub(crate) fn numbers(app: &VectorcraftApp, i: usize) -> Vec { + let Some(u) = uid(app, i) else { return vec![] }; + let mut n: Vec = + std::iter::once(shown(app, i)).chain(app.canvas.windows.views.others.iter().filter(|o| o.uid == u).map(|o| o.number)).collect(); + n.sort_unstable(); + n +} + +/// Every window, by document and then by view number: the tab order. +pub(crate) fn windows(app: &VectorcraftApp) -> Vec { + (0..app.session.documents().len()).flat_map(|i| numbers(app, i).into_iter().map(move |n| (i, n))).collect() +} + +/// The current window: the current document's shown view. +pub(crate) fn current(app: &VectorcraftApp) -> Option { + app.session.active_index().map(|i| (i, shown(app, i))) +} + +/// Window `w`'s key, which stays the same while documents before it close: its document's uid and +/// its view's number. +pub(crate) fn key(app: &VectorcraftApp, (i, n): Win) -> Option<(u64, u32)> { + uid(app, i).map(|u| (u, n)) +} + +/// The window `key` ([`key`]) names, while its document is open. +pub(crate) fn find(app: &VectorcraftApp, (u, n): (u64, u32)) -> Option { + let i = app.session.documents().iter().position(|d| d.uid == u)?; + numbers(app, i).contains(&n).then_some((i, n)) +} + +/// Window `w`'s view. +pub(crate) fn view(app: &VectorcraftApp, (i, n): Win) -> Option<&View> { + if n == shown(app, i) { + return app.views.get(i); + } + let u = uid(app, i)?; + app.canvas.windows.views.others.iter().find(|o| o.uid == u && o.number == n).map(|o| &o.view) +} + +/// Window `w`'s view, to change. +pub(crate) fn view_mut(app: &mut VectorcraftApp, (i, n): Win) -> Option<&mut View> { + if n == shown(app, i) { + return app.views.get_mut(i); + } + let u = uid(app, i)?; + app.canvas.windows.views.others.iter_mut().find(|o| o.uid == u && o.number == n).map(|o| &mut o.view) +} + +/// Unfit every window's view: each fits where it shows next. +pub(crate) fn unfit_all(app: &mut VectorcraftApp) { + for v in &mut app.views { + v.fitted = false; + } + for o in &mut app.canvas.windows.views.others { + o.view.fitted = false; + } +} + +/// The number a window's title shows: its view's while its document has more than one view. +pub(crate) fn label(app: &VectorcraftApp, (i, n): Win) -> Option { + (numbers(app, i).len() > 1).then_some(n) +} + +/// Make view `n` the one [`VectorcraftApp::views`] holds for document `i`. False when the document +/// has no such view. While document `i` is the current one, the tool is reset first, as a switch to +/// another document resets it, and with the Artboard tool the artboard the view shows becomes the +/// tool's target. +fn bring(app: &mut VectorcraftApp, (i, n): Win) -> bool { + let old = shown(app, i); + if n == old { + return app.views.get(i).is_some(); + } + let Some(u) = uid(app, i) else { return false }; + if app.views.get(i).is_none() || !app.canvas.windows.views.others.iter().any(|o| o.uid == u && o.number == n) { + return false; + } + if app.session.active_index() == Some(i) { + app.session.reset_tool(); + } + let views = &mut app.canvas.windows.views; + let (Some(other), Some(shown)) = (views.others.iter_mut().find(|o| o.uid == u && o.number == n), app.views.get_mut(i)) else { + return false; + }; + std::mem::swap(shown, &mut other.view); + other.number = old; + views.shown.insert(u, n); + if app.session.active_index() == Some(i) + && app.session.tool_id() == "artboard" + && let Some(count) = app.session.active().map(|d| d.doc.artboards.len()) + { + let board = crate::panels::artboards::selected(app, count); + crate::panels::artboards::select(app, board); + } + true +} + +/// Make window `w` the current one, as a click on its tab does. +pub(crate) fn show_window(app: &mut VectorcraftApp, w: Win) { + bring(app, w); + crate::chrome::show_document(app, w.0); +} + +/// Forget the views of documents that have closed, and keep each other view on an artboard its +/// document has. Called by [`VectorcraftApp::sync_views`]. +pub(crate) fn retain(app: &mut VectorcraftApp) { + let views = &mut app.canvas.windows.views; + if views.others.is_empty() && views.shown.is_empty() { + return; + } + let docs = app.session.documents(); + views.shown.retain(|u, _| docs.iter().any(|d| d.uid == *u)); + views.others.retain_mut(|o| match docs.iter().find(|d| d.uid == o.uid) { + Some(d) => { + o.view.artboard = o.view.artboard.min(d.doc.artboards.len().saturating_sub(1)); + true + } + None => false, + }); +} + +/// `window.newWindow`: another view of the current document, numbered after its others, which +/// starts where the current view is and becomes the current window. It opens as a tab, or in a +/// floating window of its own when the current view is in one. Returns `{index, view}`. +pub(crate) fn new_window(app: &mut VectorcraftApp) -> Result { + app.sync_views(); + let Some((i, n)) = current(app) else { return Err("no document open".into()) }; + let (Some(u), Some(view)) = (uid(app, i), app.views.get(i).copied()) else { return Err("no document open".into()) }; + let number = numbers(app, i).last().copied().unwrap_or(n) + 1; + let floats = super::floats(app, (i, n)); + app.canvas.windows.views.others.push(OtherView { uid: u, number, view }); + show_window(app, (i, number)); + if floats { + super::place(app, &[(i, number)]); + } + Ok(json!({ "index": i, "view": number })) +} + +/// Close window `w`: its view goes, and its document stays open with its other views. A +/// document's last view closes the document, asking to save it first when it has changed +/// ([`crate::unsaved::close`]). When one view is left, it is numbered 1 again. Nothing closes while +/// a file dialog's sheet is on the window. +pub(crate) fn close_window(app: &mut VectorcraftApp, (i, n): Win) -> Result { + let nums = numbers(app, i); + if nums.len() <= 1 { + return crate::unsaved::close(app, i); + } + crate::unsaved::refuse_while_picking(app)?; + let Some(u) = uid(app, i) else { return Err("no such document".into()) }; + if !nums.contains(&n) { + return Err("no such document".into()); + } + let was_current = current(app) == Some((i, n)); + // The document's next view in the tab strip takes the closing one's place. + let next = nums.iter().copied().find(|m| *m > n).or(nums.iter().copied().rfind(|m| *m != n)); + if n == shown(app, i) + && let Some(next) = next + { + bring(app, (i, next)); + } + app.canvas.windows.views.others.retain(|o| !(o.uid == u && o.number == n)); + app.ui.floating.retain(|w| !(w.uid == u && w.view == n)); + if app.ui.tab_document == Some((u, n)) { + app.ui.tab_document = None; + } + let left = numbers(app, i); + if let [only] = left[..] { + app.canvas.windows.views.shown.remove(&u); + for w in app.ui.floating.iter_mut().filter(|w| w.uid == u && w.view == only) { + w.view = 1; + } + if app.ui.tab_document == Some((u, only)) { + app.ui.tab_document = Some((u, 1)); + } + } + if was_current { + crate::chrome::show_document(app, i); + } + Ok(json!({ "index": i, "views": left.len() })) +} + +/// `file.close` without an `index`: the current window closes ([`close_window`]). +pub(crate) fn close_current(app: &mut VectorcraftApp) -> Result { + let Some(w) = current(app) else { return Err("no document open".into()) }; + close_window(app, w) +} + +/// `params`' `index` and `view`: the window they name, `Ok(None)` without an `index`. Without a +/// `view`, the document's shown view. +pub(crate) fn window_param(app: &VectorcraftApp, p: &Value) -> Result, String> { + let i = match p.get("index") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < app.session.documents().len()).ok_or("no such document")?, + }; + let n = match p.get("view") { + None | Some(Value::Null) => shown(app, i), + Some(v) => v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| numbers(app, i).contains(n)).ok_or("no such document")?, + }; + Ok(Some((i, n))) +} diff --git a/crates/ui-egui/src/document_drag.rs b/crates/ui-egui/src/document_drag.rs index 72ea5a3c1f..e85e95ae78 100644 --- a/crates/ui-egui/src/document_drag.rs +++ b/crates/ui-egui/src/document_drag.rs @@ -26,6 +26,8 @@ struct Artwork { #[derive(Clone)] struct Hover { uid: u64, + /// The hovered tab's view of its document (Window › New Window). + view: u32, since: f64, } @@ -91,14 +93,16 @@ pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) { } } -/// Hover uses `contains_pointer`: the source canvas still owns egui's dragged widget. -pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, close: egui::Rect) -> bool { +/// Hover uses `contains_pointer`: the source canvas still owns egui's dragged widget. The tab is +/// window `win`'s: another view of the current document's is a tab to switch to as well. +pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, win: crate::doc_windows::Win, close: egui::Rect) -> bool { let ctx = &resp.ctx; + let (index, view) = win; let eligible = resp.contains_pointer() && ctx .input(|i| i.pointer.primary_down() && !i.key_pressed(egui::Key::Escape) && i.pointer.interact_pos().is_some_and(|p| !close.contains(p))) && app.ui.dialog.is_none(); - if !eligible || app.session.active_index() == Some(index) || ctx.data(|d| d.get_temp::(activated_at_id()).is_some()) { + if !eligible || crate::doc_windows::current(app) == Some(win) || ctx.data(|d| d.get_temp::(activated_at_id()).is_some()) { return false; } let transfer = egui::DragAndDrop::has_payload_of_type::(ctx); @@ -110,7 +114,7 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, return false; }; let now = ctx.input(|i| i.time); - let hover = ctx.data(|d| d.get_temp::(hover_id())).filter(|h| h.uid == uid).unwrap_or(Hover { uid, since: now }); + let hover = ctx.data(|d| d.get_temp::(hover_id())).filter(|h| h.uid == uid && h.view == view).unwrap_or(Hover { uid, view, since: now }); ctx.data_mut(|d| d.insert_temp(hover_id(), hover.clone())); let remaining = HOVER_SECONDS - (now - hover.since); if remaining > 0.0 { @@ -147,7 +151,7 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, egui::DragAndDrop::set_payload(ctx, art); } app.ui.home = None; - app.session.set_active(index); + crate::doc_windows::show_window(app, win); ctx.request_repaint(); let at = ctx.input(|i| i.pointer.interact_pos()); ctx.data_mut(|d| { diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index 5720675378..eb79cf3570 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -481,15 +481,16 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { let removing = dragged.is_some_and(|d| d.from_toolbar) && ctx.memory(|m| m.area_rect(id)).is_some_and(over); let mut picked = None; let mut reset = false; - let mut new_toolbar = false; + // New Toolbar… or Manage Toolbars… in the drawer's menu. + let mut toolbars_cmd = None; let mut advanced = None; let mut show_toggle = None; let resp = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, removing).inner_margin(egui::Margin::same(8)).show(ui, |ui| { let width = WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(160.0); ui.set_width(width); - // All Tools, and the drawer's flyout menu: Reset, the Basic or Advanced toolbar, and New - // Toolbar. + // All Tools, and the drawer's flyout menu: Reset, the Basic or Advanced toolbar, New + // Toolbar and Manage Toolbars. ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("All Tools")).size(13.0).color(t.text_strong)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -510,7 +511,10 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { } ui.separator(); if widgets::menu_item(ui, "New Toolbar…", true, false) { - new_toolbar = true; + toolbars_cmd = Some("window.toolbar.new"); + } + if widgets::menu_item(ui, "Manage Toolbars…", true, false) { + toolbars_cmd = Some("window.toolbar.manage"); } }); }); @@ -571,7 +575,9 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { if reset && let Err(e) = app.run("toolbar.reset", json!({ "toolbar": target })) { app.ui.status = e; } - if new_toolbar && let Err(e) = app.run("window.toolbar.new", json!({})) { + if let Some(id) = toolbars_cmd + && let Err(e) = app.run(id, json!({})) + { app.ui.status = e; } if let Some(advanced) = advanced diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index af9a83af0b..588869226e 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -286,7 +286,7 @@ mod tests { /// Change All leaves locked text in its font, and the status bar says so. #[test] - fn change_all_says_when_locked_text_keeps_its_font() { + fn change_all_clears_the_skip_warning_after_unlocking_text() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Missing Family"})).unwrap(); @@ -298,6 +298,16 @@ mod tests { assert_eq!(r["runs"], 0, "{r}"); assert_eq!(fonts(&mut app)[0]["family"], "Missing Family"); assert_eq!(app.ui.status, "some text is hidden or locked, so its font wasn't replaced"); + app.run("object.unlockAll", json!({})).unwrap(); + assert_eq!(app.ui.status, "some text is hidden or locked, so its font wasn't replaced"); + let r = change(&mut app, &d, &from, true).unwrap(); + assert_eq!(r["runs"], 1, "{r}"); + assert!(r.get("warning").is_none(), "{r}"); + assert_eq!(app.ui.status, ""); + app.ui.status = "Saved another document".into(); + let r = app.run("text.replaceFont", json!({"from": {"family": "Missing Family"}, "to": {"family": "Source Sans 3"}})).unwrap(); + assert_eq!(r["runs"], 0, "{r}"); + assert_eq!(app.ui.status, "Saved another document"); } #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3bda3f6b84..4977e2599f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2040,6 +2040,11 @@ font-technology Variable Променливи New Toolbar Нова лента с инструменти Show Toolbar Показване на лента с инструменти List Toolbars Списък на лентите с инструменти + Manage Toolbars… Управление на лентите с инструменти… + Manage Toolbars Управление на лентите с инструменти + Rename Toolbar Преименуване на лентата с инструменти + Duplicate Toolbar Дублиране на лентата с инструменти + Delete Toolbar Изтриване на лентата с инструменти New {kind} Style… Нов стил ({kind})… New… Нов… Next Следващ @@ -3496,6 +3501,7 @@ digits Malayalam Малаялам Use Artboards Използване на работните области Use Black Point Compensation Използване на компенсация на черната точка Use Compression Използване на компресия + Include Editing Data for Other Apps Включване на данни за редактиране за други приложения Use document bleed settings Използване на настройките за изтичане на документа Use Even-Odd Fill Rule Използване на правило за запълване четно-нечетно Use Japanese Crop Marks Използване на японски марки за изрязване @@ -6026,6 +6032,7 @@ breakText Glyphs Глифи Gap 5 Разстояние 5 @msg {_1}: they came in as the page's art {_1}: те са внесени като изображението от страницата +@msg objects that drew differently from the file's own page came in as its art: {_1} обектите, изобразени различно от собствената страница на файла, са внесени като изображението от страницата: {_1} @msg You must choose a side pattern. Трябва да изберете страничен шаблон. @msg invalid pattern tile choice невалиден избор на плочка за шаблон diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 705c5d7e76..e11ef427db 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -645,6 +645,11 @@ New Toolbar Nova traka s alatima Show Toolbar Prikaži traku s alatima List Toolbars Izlistaj trake s alatima + Manage Toolbars… Upravljaj trakama s alatima… + Manage Toolbars Upravljaj trakama s alatima + Rename Toolbar Preimenuj traku s alatima + Duplicate Toolbar Dupliciraj traku s alatima + Delete Toolbar Izbriši traku s alatima Manage Workspaces… Upravljaj radnim prostorima… Control Kontrola Contextual Task Bar Kontekstna traka zadataka @@ -3630,6 +3635,7 @@ theme Appearance Izgled Type Object Selection by Path Only Odabir tekstualnog objekta samo putanjom UI Scaling Skaliranje interfejsa Use Compression Koristi kompresiju + Include Editing Data for Other Apps Uključi podatke za uređivanje za druge aplikacije Use Japanese Crop Marks Koristi japanske oznake za izrezivanje Use Low Resolution Proxy for Linked EPS Koristi proksi niske rezolucije za povezani EPS Use Precise Cursors Koristi precizne kursore @@ -4256,7 +4262,7 @@ imageTrace Outline Obris @msg Save for Web: the image is empty Sačuvaj za web: slika je prazna @msg Save writes {_1}, not {_2}: use document.export for other formats Sačuvaj zapisuje {_1}, a ne {_2}: za druge formate koristite document.export @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Sačuvaj zapisuje {_1}, a ne {_2}: za zapisivanje koristite file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 zapisuje naslijeđene .ai datoteke; .ait predložak je uvijek kompatibilan s PDF-om +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 zapisuje naslijeđene .ai datoteke, a ne .ait predloške @msg Saved recovery data for {title} Sačuvani podaci za oporavak za {title} @msg Saved to User Defined: {_1} Sačuvano u korisnički definisano: {_1} @msg Saved {path} Sačuvano: {path} @@ -6060,6 +6066,7 @@ shape Open Otvoren @msg the paths have nothing to merge there putanje tu nemaju šta spojiti @msg the path is locked or hidden putanja je zaključana ili skrivena @msg {_1}: they came in as the page's art {_1}: uvezeni su kao ilustracija stranice +@msg objects that drew differently from the file's own page came in as its art: {_1} objekti koji su se iscrtali drugačije od vlastite stranice datoteke uvezeni su kao ilustracija stranice: {_1} @msg You must choose a side pattern. Morate odabrati bočni uzorak. @msg invalid pattern tile choice nevažeći izbor pločice uzorka diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 97d2024dde..1fc553a28b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2565,6 +2565,11 @@ digits Malayalam Malaiàlam New Toolbar Barra d’eines nova Show Toolbar Mostra la barra d’eines List Toolbars Llista les barres d’eines + Manage Toolbars… Gestiona les barres d’eines… + Manage Toolbars Gestiona les barres d’eines + Rename Toolbar Canvia el nom de la barra d’eines + Duplicate Toolbar Duplica la barra d’eines + Delete Toolbar Suprimeix la barra d’eines New {kind} Style… Nou estil de {kind}… New… Nou… Next Següent @@ -4503,6 +4508,7 @@ digits Malayalam Malaiàlam Use Artboards Utilitza els taulers Use Black Point Compensation Utilitza la compensació del punt negre Use Compression Utilitza la compressió + Include Editing Data for Other Apps Inclou les dades d’edició per a altres aplicacions Use Default Utilitza el valor per defecte Use document bleed settings Utilitza la configuració de sangrat del document Use Document Raster Effects Resolution Utilitza la resolució dels efectes de ràster del document @@ -4920,7 +4926,7 @@ digits Malayalam Malaiàlam @msg a {width}×{height} screenshot's byte count overflows el recompte de bytes d’una captura de pantalla de {width}×{height} es desborda @msg action is required cal una acció @msg add a format to export afegiu un format per exportar -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 escriu fitxers .ai heretats; una plantilla .ait sempre és compatible amb PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 escriu fitxers .ai heretats, no plantilles .ait @msg already editing a pattern ja s’està editant un motiu @msg an exporter always gets an export's budget: leave `budget` out un exportador sempre rep el pressupost d’una exportació: ometeu `budget` @msg an image has no pixels and was left out una imatge no té píxels i s’ha omès @@ -6158,6 +6164,7 @@ breakText Glyphs Glifs Gap 5 Separació 5 @msg {_1}: they came in as the page's art {_1}: han entrat com a art de la pàgina +@msg objects that drew differently from the file's own page came in as its art: {_1} els objectes que es dibuixaven diferent de la pàgina pròpia del fitxer han entrat com a art de la pàgina: {_1} @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 56228845db..6443103746 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -653,6 +653,11 @@ New Toolbar Nový panel nástrojů Show Toolbar Zobrazit panel nástrojů List Toolbars Seznam panelů nástrojů + Manage Toolbars… Spravovat panely nástrojů… + Manage Toolbars Spravovat panely nástrojů + Rename Toolbar Přejmenovat panel nástrojů + Duplicate Toolbar Duplikovat panel nástrojů + Delete Toolbar Odstranit panel nástrojů Manage Workspaces… Spravovat pracovní prostory… Control Ovládací panel Contextual Task Bar Kontextový panel úloh @@ -3672,6 +3677,7 @@ theme Appearance Motiv Type Object Selection by Path Only Výběr textového objektu pouze podle cesty UI Scaling Měřítko rozhraní Use Compression Použít kompresi + Include Editing Data for Other Apps Zahrnout data pro úpravy v jiných aplikacích Use Japanese Crop Marks Použít japonské ořezové značky Use Low Resolution Proxy for Linked EPS Pro propojené EPS použít zástupce v nízkém rozlišení Use Precise Cursors Používat přesné kurzory @@ -4304,7 +4310,7 @@ imageTrace Outline Obrys @msg Save for Web: the image is empty Uložit pro web: obraz je prázdný @msg Save writes {_1}, not {_2}: use document.export for other formats Uložení zapisuje {_1}, ne {_2}: pro jiné formáty použijte document.export @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Uložení zapisuje {_1}, ne {_2}: k jeho zápisu použijte file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 zapisuje starší soubory .ai; šablona .ait je vždy kompatibilní s PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 zapisuje starší soubory .ai, ne šablony .ait @msg Saved recovery data for {title} Data pro obnovení uložena pro {title} @msg Saved to User Defined: {_1} Uloženo do uživatelských knihoven: {_1} @msg Saved {path} Uloženo: {path} @@ -6174,6 +6180,7 @@ breakText Glyphs Glyfy Gap 5 Mezera 5 @msg {_1}: they came in as the page's art {_1}: byly načteny jako kresba ze stránky +@msg objects that drew differently from the file's own page came in as its art: {_1} objekty, které se vykreslily jinak než vlastní stránka souboru, byly načteny jako kresba ze stránky: {_1} @msg You must choose a side pattern. Musíte zvolit boční vzorek. @msg invalid pattern tile choice neplatná volba dlaždice vzorku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8556ae44ec..5f4a70ae4a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -603,6 +603,11 @@ New Toolbar Ny værktøjslinje Show Toolbar Vis værktøjslinje List Toolbars Liste over værktøjslinjer + Manage Toolbars… Administrér værktøjslinjer… + Manage Toolbars Administrér værktøjslinjer + Rename Toolbar Omdøb værktøjslinje + Duplicate Toolbar Duplikér værktøjslinje + Delete Toolbar Slet værktøjslinje Manage Workspaces… Administrér arbejdsområder… Control Kontrol Contextual Task Bar Kontekstuel opgavelinje @@ -3399,6 +3404,7 @@ theme Appearance Udseende Type Object Selection by Path Only Markering af tekstobjekter udelukkende via kurve UI Scaling UI-skalering Use Compression Anvend komprimering + Include Editing Data for Other Apps Medtag redigeringsdata til andre apps Use Japanese Crop Marks Anvend japanske beskæringsmærker Use Low Resolution Proxy for Linked EPS Anvend proxy med lav opløsning til koblede EPS-filer Use Precise Cursors Anvend præcise markører @@ -6042,6 +6048,7 @@ breakText Glyphs Glyffer Gap 5 Mellemrum 5 @msg {_1}: they came in as the page's art {_1}: de blev importeret som sidens grafik +@msg objects that drew differently from the file's own page came in as its art: {_1} objekter, der blev tegnet anderledes end filens egen side, blev importeret som sidens grafik: {_1} @msg You must choose a side pattern. Du skal vælge et sidemønster. @msg invalid pattern tile choice ugyldigt valg af mønsterflise diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 11793dc22b..2038450a25 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2060,6 +2060,11 @@ file-filter Templates Vorlagen New Toolbar Neue Werkzeugleiste Show Toolbar Werkzeugleiste einblenden List Toolbars Werkzeugleisten auflisten + Manage Toolbars… Werkzeugleisten verwalten… + Manage Toolbars Werkzeugleisten verwalten + Rename Toolbar Werkzeugleiste umbenennen + Duplicate Toolbar Werkzeugleiste duplizieren + Delete Toolbar Werkzeugleiste löschen New {kind} Style… Neues {kind}format… New… Neu… Next Weiter @@ -3524,6 +3529,7 @@ effect Points Punkte Use Artboards Zeichenflächen verwenden Use Black Point Compensation Tiefenkompensierung verwenden Use Compression Komprimierung verwenden + Include Editing Data for Other Apps Bearbeitungsdaten für andere Apps einschließen Use document bleed settings Anschnitteinstellungen des Dokuments verwenden Use Even-Odd Fill Rule Gerade-Ungerade-Füllregel verwenden Use Japanese Crop Marks Japanische Schnittmarken verwenden @@ -3911,7 +3917,7 @@ theme Appearance Erscheinungsbild @msg Save for Web: the image is empty Für Web speichern: Das Bild ist leer @msg Save writes {_1}, not {_2}: use document.export for other formats „Speichern“ schreibt {_1}, nicht {_2}: Verwenden Sie document.export für andere Formate @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it „Speichern“ schreibt {_1}, nicht {_2}: Verwenden Sie dafür file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 schreibt ältere .ai-Dateien; eine .ait-Vorlage ist immer PDF-kompatibel +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 schreibt ältere .ai-Dateien, keine .ait-Vorlagen @msg Saved recovery data for {title} Wiederherstellungsdaten für {title} gespeichert @msg Saved to User Defined: {_1} In „Benutzerdefiniert“ gespeichert: {_1} @msg Saved {path} {path} gespeichert @@ -5760,6 +5766,7 @@ breakText Glyphs Glyphen Gap 5 Lücke 5 @msg {_1}: they came in as the page's art {_1}: sie wurden als Seitengrafik importiert +@msg objects that drew differently from the file's own page came in as its art: {_1} Objekte, die anders als die eigene Seite der Datei gezeichnet wurden, wurden als Seitengrafik importiert: {_1} @msg You must choose a side pattern. Sie müssen ein Seitenmuster auswählen. @msg invalid pattern tile choice ungültige Auswahl einer Musterkachel diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 687a4a8e88..807866d6e5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1725,6 +1725,11 @@ font-technology Variable Μεταβλητές New Toolbar Νέα γραμμή εργαλείων Show Toolbar Εμφάνιση γραμμής εργαλείων List Toolbars Λίστα γραμμών εργαλείων + Manage Toolbars… Διαχείριση γραμμών εργαλείων… + Manage Toolbars Διαχείριση γραμμών εργαλείων + Rename Toolbar Μετονομασία γραμμής εργαλείων + Duplicate Toolbar Δημιουργία αντιγράφου γραμμής εργαλείων + Delete Toolbar Διαγραφή γραμμής εργαλείων New… Νέο… Next Επόμενο No document Δεν υπάρχει έγγραφο @@ -2203,7 +2208,7 @@ axis Both Και τα δύο @msg Save for Web: the image is empty Αποθήκευση για ιστό: η εικόνα είναι κενή @msg Save writes {_1}, not {_2}: use document.export for other formats Η αποθήκευση γράφει {_1}, όχι {_2}: χρησιμοποιήστε το document.export για άλλες μορφές @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Η αποθήκευση γράφει {_1}, όχι {_2}: χρησιμοποιήστε το file.saveAsTemplate για να το γράψετε -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible Το aiVersion 7 γράφει αρχεία .ai παλαιού τύπου· ένα πρότυπο .ait είναι πάντα συμβατό με PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates Το aiVersion 7 γράφει αρχεία .ai παλαιού τύπου, όχι πρότυπα .ait @msg Saved recovery data for {title} Αποθηκεύτηκαν δεδομένα ανάκτησης για το {title} @msg Saved to User Defined: {_1} Αποθηκεύτηκε στις προσαρμοσμένες ρυθμίσεις: {_1} @msg Saved {path} Αποθηκεύτηκε το {path} @@ -3866,6 +3871,7 @@ digits Malayalam Μαλαγιάλαμ Use Artboards Χρήση πινάκων σχεδίασης Use Black Point Compensation Χρήση αντιστάθμισης μαύρου σημείου Use Compression Χρήση συμπίεσης + Include Editing Data for Other Apps Συμπερίληψη δεδομένων επεξεργασίας για άλλες εφαρμογές Use document bleed settings Χρήση ρυθμίσεων ξακρίσματος εγγράφου Use Even-Odd Fill Rule Χρήση κανόνα γεμίσματος άρτιου-περιττού Use Japanese Crop Marks Χρήση ιαπωνικών σημαδιών περικοπής @@ -6126,6 +6132,7 @@ breakText Glyphs Γλύφοι Gap 5 Διάκενο 5 @msg {_1}: they came in as the page's art {_1}: εισήχθησαν ως γραφικά της σελίδας +@msg objects that drew differently from the file's own page came in as its art: {_1} τα αντικείμενα που σχεδιάστηκαν διαφορετικά από την ίδια τη σελίδα του αρχείου εισήχθησαν ως γραφικά της σελίδας: {_1} @msg You must choose a side pattern. Πρέπει να επιλέξετε πλευρικό μοτίβο. @msg invalid pattern tile choice μη έγκυρη επιλογή πλακιδίου μοτίβου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index eb0aeaeaba..cc2f82edc7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2107,6 +2107,11 @@ font-technology Variable Variables New Toolbar Nueva barra de herramientas Show Toolbar Mostrar barra de herramientas List Toolbars Lista de barras de herramientas + Manage Toolbars… Administrar barras de herramientas… + Manage Toolbars Administrar barras de herramientas + Rename Toolbar Cambiar nombre de la barra de herramientas + Duplicate Toolbar Duplicar barra de herramientas + Delete Toolbar Eliminar barra de herramientas New {kind} Style… Nuevo estilo de {kind}… New… Nuevo… Next Siguiente @@ -3580,6 +3585,7 @@ digits Malayalam Malayálam Use Artboards Usar mesas de trabajo Use Black Point Compensation Usar compensación de punto negro Use Compression Usar compresión + Include Editing Data for Other Apps Incluir datos de edición para otras aplicaciones Use document bleed settings Usar los ajustes de sangrado del documento Use Even-Odd Fill Rule Usar regla de relleno par-impar Use Japanese Crop Marks Usar marcas de corte japonesas @@ -3996,7 +4002,7 @@ axis Both Ambos @msg Save for Web: the image is empty Guardar para web: la imagen está vacía @msg Save writes {_1}, not {_2}: use document.export for other formats Guardar escribe {_1}, no {_2}: usa document.export para otros formatos @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Guardar escribe {_1}, no {_2}: usa file.saveAsTemplate para escribirlo -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 escribe archivos .ai heredados; una plantilla .ait siempre es compatible con PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 escribe archivos .ai heredados, no plantillas .ait @msg Saved recovery data for {title} Se guardaron los datos de recuperación de {title} @msg Saved to User Defined: {_1} Se guardó en Definido por el usuario: {_1} @msg Saved {path} Se guardó {path} @@ -6116,6 +6122,7 @@ breakText Glyphs Glifos Gap 5 Hueco 5 @msg {_1}: they came in as the page's art {_1}: se importaron como el arte de la página +@msg objects that drew differently from the file's own page came in as its art: {_1} los objetos que se dibujaban de forma distinta a la página del propio archivo se importaron como el arte de la página: {_1} @msg You must choose a side pattern. Debe elegir un motivo lateral. @msg invalid pattern tile choice selección de mosaico de motivo no válida diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 806ad1dbd7..b03abbcd0c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -644,6 +644,11 @@ New Toolbar Uusi työkalurivi Show Toolbar Näytä työkalurivi List Toolbars Luettele työkalurivit + Manage Toolbars… Hallitse työkalurivejä… + Manage Toolbars Hallitse työkalurivejä + Rename Toolbar Nimeä työkalurivi uudelleen + Duplicate Toolbar Monista työkalurivi + Delete Toolbar Poista työkalurivi Manage Workspaces… Hallitse työtiloja… Control Ohjaus Contextual Task Bar Kontekstuaalinen tehtäväpalkki @@ -3596,6 +3601,7 @@ theme Appearance Ulkoasu Type Object Selection by Path Only Tekstiobjektin valinta vain polusta UI Scaling Käyttöliittymän skaalaus Use Compression Käytä pakkausta + Include Editing Data for Other Apps Sisällytä muokkaustiedot muille sovelluksille Use Japanese Crop Marks Käytä japanilaisia rajausmerkkejä Use Low Resolution Proxy for Linked EPS Käytä matalan tarkkuuden välityskuvaa linkitetyille EPS-tiedostoille Use Precise Cursors Käytä tarkkoja kohdistimia @@ -4218,7 +4224,7 @@ imageTrace Outline Ääriviiva @msg Save for Web: the image is empty Tallenna verkkoon: kuva on tyhjä @msg Save writes {_1}, not {_2}: use document.export for other formats Tallenna kirjoittaa muotoa {_1}, ei {_2}: käytä muille muodoille komentoa document.export @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Tallenna kirjoittaa muotoa {_1}, ei {_2}: kirjoita se komennolla file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 kirjoittaa vanhoja .ai-tiedostoja; .ait-malli on aina PDF-yhteensopiva +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 kirjoittaa vanhoja .ai-tiedostoja, ei .ait-malleja @msg Saved recovery data for {title} Tallennettiin palautustiedot: {title} @msg Saved to User Defined: {_1} Tallennettiin kohteeseen Käyttäjän määrittämä: {_1} @msg Saved {path} Tallennettiin {path} @@ -6204,6 +6210,7 @@ breakText Glyphs Merkit Gap 5 Väli 5 @msg {_1}: they came in as the page's art {_1}: ne tuotiin sivun piirroksena +@msg objects that drew differently from the file's own page came in as its art: {_1} objektit, jotka piirtyivät eri tavoin kuin tiedoston oma sivu, tuotiin sivun piirroksena: {_1} @msg You must choose a side pattern. Valitse sivukuvio. @msg invalid pattern tile choice virheellinen kuviolaatan valinta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f646102576..935dc6f070 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2099,6 +2099,11 @@ font-technology Variable Variables New Toolbar Nouvelle barre d’outils Show Toolbar Afficher la barre d’outils List Toolbars Liste des barres d’outils + Manage Toolbars… Gérer les barres d’outils… + Manage Toolbars Gérer les barres d’outils + Rename Toolbar Renommer la barre d’outils + Duplicate Toolbar Dupliquer la barre d’outils + Delete Toolbar Supprimer la barre d’outils New {kind} Style… Nouveau style de {kind}… New… Nouveau… Next Suivant @@ -3572,6 +3577,7 @@ digits Malayalam Malayalam Use Artboards Utiliser les plans de travail Use Black Point Compensation Utiliser la compensation du point noir Use Compression Utiliser la compression + Include Editing Data for Other Apps Inclure les données de modification pour d’autres applications Use document bleed settings Utiliser les paramètres de fond perdu du document Use Even-Odd Fill Rule Utiliser la règle de remplissage pair-impair Use Japanese Crop Marks Utiliser les traits de coupe japonais @@ -3986,7 +3992,7 @@ axis Both Les deux @msg Save for Web: the image is empty Enregistrer pour le Web : l’image est vide @msg Save writes {_1}, not {_2}: use document.export for other formats Enregistrer écrit du {_1}, pas du {_2} : utilisez document.export pour les autres formats @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Enregistrer écrit du {_1}, pas du {_2} : utilisez file.saveAsTemplate pour l’écrire -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 écrit des fichiers .ai hérités, alors qu’un modèle .ait est toujours compatible PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 écrit des fichiers .ai hérités, pas des modèles .ait @msg Saved recovery data for {title} Données de récupération enregistrées pour {title} @msg Saved to User Defined: {_1} Enregistré dans Défini par l’utilisateur : {_1} @msg Saved {path} {path} enregistré @@ -6169,6 +6175,7 @@ breakText Glyphs Glyphes Gap 5 Écart 5 @msg {_1}: they came in as the page's art {_1} : ils ont été importés comme le dessin de la page +@msg objects that drew differently from the file's own page came in as its art: {_1} les objets dessinés différemment de la page du fichier lui-même ont été importés comme le dessin de la page : {_1} @msg You must choose a side pattern. Vous devez choisir un motif latéral. @msg invalid pattern tile choice choix de tuile de motif non valide diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d5c9f279ad..363db7e265 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2562,6 +2562,11 @@ file-filter Templates Sablonok New Toolbar Új eszköztár Show Toolbar Eszköztár megjelenítése List Toolbars Eszköztárak listája + Manage Toolbars… Eszköztárak kezelése… + Manage Toolbars Eszköztárak kezelése + Rename Toolbar Eszköztár átnevezése + Duplicate Toolbar Eszköztár kettőzése + Delete Toolbar Eszköztár törlése New {kind} Style… Új stílus: {kind}… New… Új… Next Következő @@ -4293,6 +4298,7 @@ mark-style Japanese Japán Use Artboards Rajztáblák használata Use Black Point Compensation Feketepont-kompenzáció használata Use Compression Tömörítés használata + Include Editing Data for Other Apps Szerkesztési adatok hozzáadása más alkalmazásokhoz Use Default Alapértelmezett használata Use document bleed settings A dokumentum kifutóbeállításainak használata Use Even-Odd Fill Rule Páros-páratlan kitöltési szabály @@ -5103,7 +5109,7 @@ imageTrace Outline Körvonal @msg Save for Web: the image is empty Mentés webre: a kép üres @msg Save writes {_1}, not {_2}: use document.export for other formats a Mentés ezt írja: {_1}, ezt nem: {_2}; más formátumhoz használd a document.export parancsot @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it a Mentés ezt írja: {_1}, ezt nem: {_2}; ennek írásához használd a file.saveAsTemplate parancsot -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible az aiVersion 7 régi .ai fájlokat ír; egy .ait sablon mindig PDF-kompatibilis +@msg aiVersion 7 writes legacy .ai files, not .ait templates az aiVersion 7 régi .ai fájlokat ír, nem .ait sablonokat @msg saved panel layout is invalid; reset the workspace first a mentett panelelrendezés érvénytelen; előbb állítsd vissza a munkaterületet @msg Saved recovery data for {title} Helyreállítási adatok mentve: {title} @msg Saved to User Defined: {_1} Mentve a felhasználóiak közé: {_1} @@ -6158,6 +6164,7 @@ breakText Glyphs Glifák Gap 5 Hézag 5 @msg {_1}: they came in as the page's art {_1}: az oldal rajzaként lettek importálva +@msg objects that drew differently from the file's own page came in as its art: {_1} a fájl saját oldalától eltérően rajzolt objektumok az oldal rajzaként lettek importálva: {_1} @msg You must choose a side pattern. Válasszon oldalsó mintát. @msg invalid pattern tile choice érvénytelen mintacsempe-választás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 3e784fe339..7faf03f43a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -603,6 +603,11 @@ New Toolbar Bilah alat baru Show Toolbar Tampilkan bilah alat List Toolbars Daftar bilah alat + Manage Toolbars… Kelola bilah alat… + Manage Toolbars Kelola bilah alat + Rename Toolbar Ganti nama bilah alat + Duplicate Toolbar Duplikatkan bilah alat + Delete Toolbar Hapus bilah alat Manage Workspaces… Kelola ruang kerja… Control Kontrol Contextual Task Bar Bilah tugas kontekstual @@ -3399,6 +3404,7 @@ theme Appearance Tampilan Type Object Selection by Path Only Pemilihan objek teks hanya berdasarkan jalur UI Scaling Penskalaan UI Use Compression Gunakan kompresi + Include Editing Data for Other Apps Sertakan data penyuntingan untuk aplikasi lain Use Japanese Crop Marks Gunakan tanda potong Jepang Use Low Resolution Proxy for Linked EPS Gunakan proksi resolusi rendah untuk EPS tertaut Use Precise Cursors Gunakan kursor presisi @@ -6042,6 +6048,7 @@ breakText Glyphs Glif Gap 5 Jarak 5 @msg {_1}: they came in as the page's art {_1}: diimpor sebagai gambar halaman +@msg objects that drew differently from the file's own page came in as its art: {_1} objek yang tergambar berbeda dari halaman file itu sendiri diimpor sebagai gambar halaman: {_1} @msg You must choose a side pattern. Anda harus memilih pola sisi. @msg invalid pattern tile choice pilihan ubin pola tidak valid diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c95b9d71d5..1c34cd4dfb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2136,6 +2136,11 @@ file-filter Templates Modelli New Toolbar Nuova barra degli strumenti Show Toolbar Mostra barra degli strumenti List Toolbars Elenco barre degli strumenti + Manage Toolbars… Gestisci barre degli strumenti… + Manage Toolbars Gestisci barre degli strumenti + Rename Toolbar Rinomina barra degli strumenti + Duplicate Toolbar Duplica barra degli strumenti + Delete Toolbar Elimina barra degli strumenti New {kind} Style… Nuovo stile {kind}… New… Nuovo… Next Successivo @@ -3617,6 +3622,7 @@ digits Malayalam Malayalam Use Artboards Usa tavole da disegno Use Black Point Compensation Usa compensazione punto nero Use Compression Usa compressione + Include Editing Data for Other Apps Includi dati di modifica per altre app Use document bleed settings Usa impostazioni abbondanza del documento Use Even-Odd Fill Rule Usa regola di riempimento pari-dispari Use Japanese Crop Marks Usa segni di ritaglio giapponesi @@ -4038,7 +4044,7 @@ axis Both Entrambi @msg Save for Web: the image is empty Salva per il web: l'immagine è vuota @msg Save writes {_1}, not {_2}: use document.export for other formats Salva scrive {_1}, non {_2}: usa document.export per gli altri formati @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Salva scrive {_1}, non {_2}: usa file.saveAsTemplate per scriverlo -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 scrive file .ai tradizionali; un modello .ait è sempre compatibile PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 scrive file .ai tradizionali, non modelli .ait @msg Saved recovery data for {title} Dati di recupero salvati per {title} @msg Saved to User Defined: {_1} Salvato in Definito dall'utente: {_1} @msg Saved {path} Salvato {path} @@ -5794,6 +5800,7 @@ breakText Glyphs Glifi Gap 5 Spazio 5 @msg {_1}: they came in as the page's art {_1}: sono stati importati come elementi grafici della pagina +@msg objects that drew differently from the file's own page came in as its art: {_1} gli oggetti disegnati in modo diverso dalla pagina del file stesso sono stati importati come elementi grafici della pagina: {_1} @msg You must choose a side pattern. Devi scegliere un motivo laterale. @msg invalid pattern tile choice scelta della tessera motivo non valida diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index eb3d1fc5e9..f1cf9a5378 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -658,6 +658,11 @@ digits Malayalam マラヤーラム数字 New Toolbar 新規ツールバー Show Toolbar ツールバーを表示 List Toolbars ツールバーの一覧 + Manage Toolbars… ツールバーを管理… + Manage Toolbars ツールバーを管理 + Rename Toolbar ツールバーの名前を変更 + Duplicate Toolbar ツールバーを複製 + Delete Toolbar ツールバーを削除 Manage Workspaces… ワークスペースを管理… Control コントロール Contextual Task Bar コンテキストタスクバー @@ -3275,6 +3280,7 @@ mark-style Japanese 日本式 Use Artboards アートボードを使用 Use Black Point Compensation ブラックポイントの補正を使用 Use Compression 圧縮を使用 + Include Editing Data for Other Apps 他のアプリ用の編集データを含める Use Even-Odd Fill Rule 奇偶規則を使用 Use Japanese Crop Marks 日本式トンボを使用 Use Low Resolution Proxy for Linked EPS リンク EPS に低解像度プロキシを使用 @@ -4153,7 +4159,7 @@ swatch Bright Violet ビビッドバイオレット @msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: この環境にはファイルシステムがありません (開くには dataBase64 を渡し、dataBase64 を受け取るにはパスを省略してください) @msg Save writes {_1}, not {_2}: use document.export for other formats 保存できる形式は {_1} のみで、{_2} 形式では保存できません: ほかの形式には document.export を使用してください @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it 保存できる形式は {_1} のみで、{_2} 形式では保存できません: この形式で保存するには file.saveAsTemplate を使用してください -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 は従来の .ai ファイルを書き出します。.ait テンプレートは常に PDF 互換です +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 は従来の .ai ファイルを書き出し、.ait テンプレートは書き出しません @msg no changes since the last save 前回の保存以降、変更はありません @msg unknown format `{f}` (Save writes {_1}) `{f}` は不明な形式です (保存できる形式: {_1}) @msg `svg` must be an object of SVG options (see document.formats) `svg` には SVG オプションのオブジェクトを指定してください (document.formats を参照) @@ -5859,6 +5865,7 @@ breakText Glyphs 字形 Gap 5 間隔 5 @msg {_1}: they came in as the page's art {_1}: ページのアートとして読み込みました +@msg objects that drew differently from the file's own page came in as its art: {_1} ファイル自身のページと描画が異なるオブジェクトをページのアートとして読み込みました: {_1} @msg You must choose a side pattern. 側面パターンを選択してください。 @msg invalid pattern tile choice パターンタイルの選択が無効です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a504987b65..87e32accb8 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -32,6 +32,14 @@ @msg {key} must be [x, y] with finite coordinates {key} უნდა იყოს [x, y] სასრული კოორდინატებით @msg ids must contain 1–10000 object ids ids უნდა შეიცავდეს 1–10000 ობიექტის იდენტიფიკატორს + Kaku #2 Kaku #2 + Kaku #3 Kaku #3 + PDF/X-1a:2001 PDF/X-1a:2001 + PDF/X-3:2002 PDF/X-3:2002 + PDF/X-4:2010 PDF/X-4:2010 + Simplified Chinese გამარტივებული ჩინური + Yonroku-ban Yonroku-ban +@id file.close ფაილის დახურვა File ფაილი New… ახალი… New Without Dialog ახალი დიალოგის გარეშე @@ -547,6 +555,11 @@ New Toolbar ახალი ხელსაწყოთა ზოლი Show Toolbar ხელსაწყოთა ზოლის ჩვენება List Toolbars ხელსაწყოთა ზოლების სია + Manage Toolbars… ხელსაწყოთა ზოლების მართვა… + Manage Toolbars ხელსაწყოთა ზოლების მართვა + Rename Toolbar ხელსაწყოთა ზოლის სახელის შეცვლა + Duplicate Toolbar ხელსაწყოთა ზოლის დუბლირება + Delete Toolbar ხელსაწყოთა ზოლის წაშლა Manage Workspaces… სამუშაო სივრცეების მართვა… Control მართვა Contextual Task Bar კონტექსტური ამოცანების ზოლი @@ -3209,6 +3222,7 @@ theme Appearance იერსახე Type Object Selection by Path Only ტექსტური ობიექტის მონიშვნა მხოლოდ ტრაექტორიით UI Scaling ინტერფეისის მასშტაბირება Use Compression შეკუმშვის გამოყენება + Include Editing Data for Other Apps სხვა აპებისთვის რედაქტირების მონაცემების ჩართვა Use Japanese Crop Marks იაპონური ჭრის ნიშნების გამოყენება Use Low Resolution Proxy for Linked EPS დაბალი გარჩევადობის პროქსის გამოყენება დაკავშირებული EPS-ისთვის Use Precise Cursors ზუსტი კურსორების გამოყენება @@ -5521,7 +5535,7 @@ imageTrace Outline კონტური @msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} მისი რედაქტირების მონაცემები {w} × {h} pt გვერდს ეკუთვნის: ეს ფაილის {pw} × {ph} pt გვერდია მასშტაბით {scale} @msg no history to clear გასასუფთავებელი ისტორია არ არის @msg {_1} files are written by file.saveAsTemplate {_1} ფაილებს წერს file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 წერს ძველი ფორმატის .ai ფაილებს; .ait შაბლონი ყოველთვის PDF-თან თავსებადია +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 წერს ძველი ფორმატის .ai ფაილებს და არა .ait შაბლონებს @msg the file's variable library was partly read (entries skipped: {_1}): the Variables panel lists the rest ფაილის ცვლადების ბიბლიოთეკა მხოლოდ ნაწილობრივ იქნა წაკითხული (გამოტოვებული ჩანაწერები: {_1}): პანელი „ცვლადები“ დანარჩენს აჩვენებს @msg the file's variable library couldn't be read: the Variables panel doesn't list it ფაილის ცვლადების ბიბლიოთეკის წაკითხვა ვერ მოხერხდა: პანელი „ცვლადები“ მას არ აჩვენებს @msg a variable of unknown kind can't be bound to objects უცნობი სახის ცვლადის ობიექტებთან დაკავშირება შეუძლებელია @@ -6162,6 +6176,7 @@ breakText Glyphs გლიფები Gap 5 შუალედი 5 @msg {_1}: they came in as the page's art {_1}: იმპორტირებულია გვერდის გამოსახულების სახით +@msg objects that drew differently from the file's own page came in as its art: {_1} ობიექტები, რომლებიც ფაილის საკუთარი გვერდისგან განსხვავებულად იხატებოდა, იმპორტირებულია გვერდის გამოსახულების სახით: {_1} @msg You must choose a side pattern. აირჩიეთ გვერდითი ნიმუში. @msg invalid pattern tile choice ნიმუშის ფილა არასწორად არის არჩეული @@ -6263,6 +6278,21 @@ breakText Glyphs გლიფები Create Swatch Legend ფერის ნიმუშების ლეგენდის შექმნა Create Swatch Legend… ფერის ნიმუშების ლეგენდის შექმნა… HEX HEX + Check for Updates… განახლებების შემოწმება… + VectorCraft Updates VectorCraft-ის განახლებები + Preparing the image… გამოსახულების მომზადება… + Tracing the colors… ფერების ვექტორიზაცია… + Invalid panel layout; restored the default dock პანელების განლაგება არასწორია; აღდგენილია ნაგულისხმევი დოკი + Include All Subdirectories ყველა ქვესაქაღალდის ჩართვა + Save and Close შენახვა და დახურვა + Load Variable Library ცვლადების ბიბლიოთეკის ჩატვირთვა +shape Open ღია +font-script Japanese იაპონური + Japanese Postcard იაპონური ღია ბარათი + Japanese Double Postcard იაპონური ორმაგი ღია ბარათი + Japanese Envelope #3 იაპონური კონვერტი #3 + Japanese Envelope #4 იაპონური კონვერტი #4 +@msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები Text Tweaker… ტექსტის მორგება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1702c2d15d..bc96cb4478 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -651,6 +651,11 @@ digits Malayalam 말라얄람어 New Toolbar 새 도구 막대 Show Toolbar 도구 막대 표시 List Toolbars 도구 막대 목록 + Manage Toolbars… 도구 막대 관리… + Manage Toolbars 도구 막대 관리 + Rename Toolbar 도구 막대 이름 바꾸기 + Duplicate Toolbar 도구 막대 복제 + Delete Toolbar 도구 막대 삭제 Manage Workspaces… 작업 영역 관리… Control 컨트롤 Contextual Task Bar 상황별 작업 표시줄 @@ -3264,6 +3269,7 @@ mark-style Japanese 일본식 Use Artboards 아트보드 사용 Use Black Point Compensation 블랙 포인트 보정 사용 Use Compression 압축 사용 + Include Editing Data for Other Apps 다른 앱용 편집 데이터 포함 Use Even-Odd Fill Rule 짝홀 칠 규칙 사용 Use Japanese Crop Marks 일본식 자르기 표시 사용 Use Low Resolution Proxy for Linked EPS 연결된 EPS에 저해상도 프록시 사용 @@ -4080,7 +4086,7 @@ theme Appearance 외관 @msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: 여기에는 파일 시스템이 없습니다(열려면 dataBase64를 전달하고, dataBase64를 얻으려면 경로를 생략하세요) @msg Save writes {_1}, not {_2}: use document.export for other formats 저장은 {_2}이(가) 아닌 {_1}(으)로 기록합니다. 다른 형식은 document.export를 사용하세요 @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it 저장은 {_2}이(가) 아닌 {_1}(으)로 기록합니다. 이 형식으로 기록하려면 file.saveAsTemplate를 사용하세요 -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7은(는) 레거시 .ai 파일을 기록합니다. .ait 템플릿은 항상 PDF 호환입니다 +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7은(는) 레거시 .ai 파일을 기록하며 .ait 템플릿은 기록하지 않습니다 @msg no changes since the last save 마지막 저장 이후 변경 내용이 없습니다 @msg unknown format `{f}` (Save writes {_1}) 알 수 없는 형식 `{f}`(저장은 {_1}(으)로 기록) @msg `svg` must be an object of SVG options (see document.formats) `svg`는 SVG 옵션 개체여야 합니다(document.formats 참조) @@ -5755,6 +5761,7 @@ breakText Glyphs 글리프 Gap 5 간격 5 @msg {_1}: they came in as the page's art {_1}: 페이지의 아트로 가져왔습니다 +@msg objects that drew differently from the file's own page came in as its art: {_1} 파일 자체 페이지와 다르게 그려진 오브젝트를 페이지의 아트로 가져왔습니다: {_1} @msg You must choose a side pattern. 측면 패턴을 선택해야 합니다. @msg invalid pattern tile choice 잘못된 패턴 타일 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 263545d27c..ddaa5df323 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -617,6 +617,11 @@ New Toolbar Nieuwe werkbalk Show Toolbar Werkbalk tonen List Toolbars Lijst met werkbalken + Manage Toolbars… Werkbalken beheren… + Manage Toolbars Werkbalken beheren + Rename Toolbar Werkbalk hernoemen + Duplicate Toolbar Werkbalk dupliceren + Delete Toolbar Werkbalk verwijderen Manage Workspaces… Werkruimten beheren… Control Bediening Contextual Task Bar Contextuele taakbalk @@ -3413,6 +3418,7 @@ theme Appearance Vormgeving Type Object Selection by Path Only Tekstobjecten alleen via hun pad selecteren UI Scaling UI-schaling Use Compression Compressie gebruiken + Include Editing Data for Other Apps Bewerkingsgegevens voor andere apps opnemen Use Japanese Crop Marks Japanse aftekenlijnen gebruiken Use Low Resolution Proxy for Linked EPS Proxy met lage resolutie gebruiken voor gekoppelde EPS Use Precise Cursors Nauwkeurige cursors gebruiken @@ -6086,6 +6092,7 @@ breakText Glyphs Glyphs Gap 5 Tussenruimte 5 @msg {_1}: they came in as the page's art {_1}: ze zijn geïmporteerd als pagina-afbeelding +@msg objects that drew differently from the file's own page came in as its art: {_1} objecten die anders werden getekend dan de eigen pagina van het bestand, zijn geïmporteerd als pagina-afbeelding: {_1} @msg You must choose a side pattern. Kies een zijpatroon. @msg invalid pattern tile choice ongeldige keuze voor patroontegel diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a2bce6ac59..8dd10bf76b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -603,6 +603,11 @@ New Toolbar Nowy pasek narzędzi Show Toolbar Pokaż pasek narzędzi List Toolbars Lista pasków narzędzi + Manage Toolbars… Zarządzaj paskami narzędzi… + Manage Toolbars Zarządzaj paskami narzędzi + Rename Toolbar Zmień nazwę paska narzędzi + Duplicate Toolbar Powiel pasek narzędzi + Delete Toolbar Usuń pasek narzędzi Manage Workspaces… Zarządzaj obszarami roboczymi… Control Sterowanie Contextual Task Bar Kontekstowy pasek zadań @@ -3535,6 +3540,7 @@ theme Appearance Wygląd Type Object Selection by Path Only Zaznaczanie obiektów tekstowych tylko po ścieżce UI Scaling Skalowanie interfejsu Use Compression Używaj kompresji + Include Editing Data for Other Apps Dołącz dane edycji dla innych aplikacji Use Japanese Crop Marks Używaj japońskich znaczników przycięcia Use Low Resolution Proxy for Linked EPS Używaj podglądu niskiej rozdzielczości dla połączonych EPS Use Precise Cursors Używaj precyzyjnych kursorów @@ -4127,7 +4133,7 @@ imageTrace Outline Kontur @msg Save for Web: the image is empty Zapisz dla Internetu: obraz jest pusty @msg Save writes {_1}, not {_2}: use document.export for other formats Zapis tworzy {_1}, nie {_2}: dla innych formatów użyj document.export @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Zapis tworzy {_1}, nie {_2}: aby go zapisać, użyj file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 zapisuje starsze pliki .ai; szablon .ait jest zawsze zgodny z PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 zapisuje starsze pliki .ai, a nie szablony .ait @msg Saved recovery data for {title} Zapisano dane odzyskiwania dla {title} @msg Saved to User Defined: {_1} Zapisano w zdefiniowanych przez użytkownika: {_1} @msg Saved {path} Zapisano {path} @@ -6029,6 +6035,7 @@ breakText Glyphs Glify Gap 5 Odstęp 5 @msg {_1}: they came in as the page's art {_1}: zaimportowano je jako grafikę strony +@msg objects that drew differently from the file's own page came in as its art: {_1} obiekty rysowane inaczej niż własna strona pliku zaimportowano jako grafikę strony: {_1} @msg You must choose a side pattern. Musisz wybrać wzór boczny. @msg invalid pattern tile choice nieprawidłowy wybór kafelka wzoru diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b6efe4bf7b..33a3f312fc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1724,6 +1724,11 @@ New Toolbar Nova Barra de Ferramentas Show Toolbar Mostrar Barra de Ferramentas List Toolbars Listar Barras de Ferramentas + Manage Toolbars… Gerenciar Barras de Ferramentas… + Manage Toolbars Gerenciar Barras de Ferramentas + Rename Toolbar Renomear Barra de Ferramentas + Duplicate Toolbar Duplicar Barra de Ferramentas + Delete Toolbar Excluir Barra de Ferramentas New file Novo arquivo New from Template Novo a partir de Modelo New from Template… Novo a partir de Modelo… @@ -2942,6 +2947,7 @@ digits Malayalam Malaiala Use Artboards Usar Mesas de Trabalho Use Black Point Compensation Usar Compensação de Ponto Preto Use Compression Usar Compressão + Include Editing Data for Other Apps Incluir Dados de Edição para Outros Aplicativos Use Even-Odd Fill Rule Usar Regra de Preenchimento Par-Ímpar Use Japanese Crop Marks Usar Marcas de Corte Japonesas Use Low Resolution Proxy for Linked EPS Usar Proxy de Baixa Resolução para EPS Vinculado @@ -3590,7 +3596,7 @@ axis Both Ambos @msg Save for Web: the image is empty Salvar para Web: a imagem está vazia @msg Save writes {_1}, not {_2}: use document.export for other formats Salvar grava {_1}, não {_2}: use document.export para outros formatos @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Salvar grava {_1}, não {_2}: use file.saveAsTemplate para gravá-lo -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 grava arquivos .ai legados; um modelo .ait é sempre compatível com PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 grava arquivos .ai legados, não modelos .ait @msg Saved recovery data for {title} Dados de recuperação de {title} salvos @msg Saved to User Defined: {_1} Salvo em Definido pelo usuário: {_1} @msg Saved {path} {path} salvo @@ -6092,6 +6098,7 @@ breakText Glyphs Glifos Gap 5 Lacuna 5 @msg {_1}: they came in as the page's art {_1}: foram importados como arte da página +@msg objects that drew differently from the file's own page came in as its art: {_1} os objetos desenhados de forma diferente da própria página do arquivo foram importados como arte da página: {_1} @msg You must choose a side pattern. Você deve escolher um padrão lateral. @msg invalid pattern tile choice escolha inválida de ladrilho de padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 999b1f1553..08c587c209 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2131,6 +2131,11 @@ file-filter Templates Шаблоны New Toolbar Новая панель инструментов Show Toolbar Показать панель инструментов List Toolbars Список панелей инструментов + Manage Toolbars… Управление панелями инструментов… + Manage Toolbars Управление панелями инструментов + Rename Toolbar Переименовать панель инструментов + Duplicate Toolbar Дублировать панель инструментов + Delete Toolbar Удалить панель инструментов New {kind} Style… Новый стиль {kind}… New… Создать… Next Далее @@ -3612,6 +3617,7 @@ digits Malayalam Малаялам Use Artboards Использовать монтажные области Use Black Point Compensation Использовать компенсацию чёрной точки Use Compression Использовать сжатие + Include Editing Data for Other Apps Включать данные редактирования для других приложений Use document bleed settings Использовать параметры вылета документа Use Even-Odd Fill Rule Использовать правило заливки «чёт-нечет» Use Japanese Crop Marks Использовать японские метки обрезки @@ -4033,7 +4039,7 @@ axis Both Оба @msg Save for Web: the image is empty Сохранить для web: изображение пусто @msg Save writes {_1}, not {_2}: use document.export for other formats Сохранение записывает {_1}, а не {_2}: используйте document.export для других форматов @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Сохранение записывает {_1}, а не {_2}: чтобы записать его, используйте file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 записывает устаревшие файлы .ai; шаблон .ait всегда совместим с PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 записывает устаревшие файлы .ai, а не шаблоны .ait @msg Saved recovery data for {title} Сохранены данные восстановления для {title} @msg Saved to User Defined: {_1} Сохранено в «Пользовательский»: {_1} @msg Saved {path} Сохранено: {path} @@ -6151,6 +6157,7 @@ breakText Glyphs Глифы Gap 5 Зазор 5 @msg {_1}: they came in as the page's art {_1}: импортированы как графика страницы +@msg objects that drew differently from the file's own page came in as its art: {_1} объекты, которые отрисовывались иначе, чем собственная страница файла, импортированы как графика страницы: {_1} @msg You must choose a side pattern. Выберите боковой узор. @msg invalid pattern tile choice недопустимый выбор плитки узора diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7f815addf6..b9112257c2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2046,6 +2046,11 @@ font-technology Variable Variabla New Toolbar Nytt verktygsfält Show Toolbar Visa verktygsfält List Toolbars Lista verktygsfält + Manage Toolbars… Hantera verktygsfält… + Manage Toolbars Hantera verktygsfält + Rename Toolbar Byt namn på verktygsfält + Duplicate Toolbar Duplicera verktygsfält + Delete Toolbar Radera verktygsfält New {kind} Style… Ny {kind}stil… New… Ny… Next Nästa @@ -3502,6 +3507,7 @@ digits Malayalam Malayalam Use Artboards Använd ritytor Use Black Point Compensation Använd svartpunktskompensering Use Compression Använd komprimering + Include Editing Data for Other Apps Inkludera redigeringsdata för andra appar Use document bleed settings Använd dokumentets inställningar för utfall Use Even-Odd Fill Rule Använd fyllningsregeln jämn-udda Use Japanese Crop Marks Använd japanska beskärningsmärken @@ -3907,7 +3913,7 @@ axis Both Båda @msg Save for Web: the image is empty Spara för webb: bilden är tom @msg Save writes {_1}, not {_2}: use document.export for other formats Spara skriver {_1}, inte {_2}: använd document.export för andra format @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Spara skriver {_1}, inte {_2}: använd file.saveAsTemplate för att skriva det -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 skriver äldre .ai-filer; en .ait-mall är alltid PDF-kompatibel +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 skriver äldre .ai-filer, inte .ait-mallar @msg Saved recovery data for {title} Sparade återställningsdata för {title} @msg Saved to User Defined: {_1} Sparat i Användardefinierat: {_1} @msg Saved {path} Sparade {path} @@ -6226,6 +6232,7 @@ breakText Glyphs Glyfer Gap 5 Mellanrum 5 @msg {_1}: they came in as the page's art {_1}: de importerades som sidans bild +@msg objects that drew differently from the file's own page came in as its art: {_1} objekt som ritades annorlunda än filens egen sida importerades som sidans bild: {_1} @msg You must choose a side pattern. Du måste välja ett sidomönster. @msg invalid pattern tile choice ogiltigt val av mönsterplatta diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0517c552a8..df46c7f2ba 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1918,6 +1918,11 @@ font-technology Variable Değişken New Toolbar Yeni Araç Çubuğu Show Toolbar Araç Çubuğunu Göster List Toolbars Araç Çubuklarını Listele + Manage Toolbars… Araç Çubuklarını Yönet… + Manage Toolbars Araç Çubuklarını Yönet + Rename Toolbar Araç Çubuğunu Yeniden Adlandır + Duplicate Toolbar Araç Çubuğunu Çoğalt + Delete Toolbar Araç Çubuğunu Sil New {kind} Style… Yeni {kind} Stili… New… Yeni… Next Sonraki @@ -3392,6 +3397,7 @@ digits Malayalam Malayalam Use Artboards Çalışma Yüzeylerini Kullan Use Black Point Compensation Siyah Nokta Telafisi Kullan Use Compression Sıkıştırmayı Kullan + Include Editing Data for Other Apps Diğer Uygulamalar İçin Düzenleme Verilerini Ekle Use document bleed settings Belge taşma payı ayarlarını kullan Use Even-Odd Fill Rule Çift-Tek Doldurma Kuralını Kullan Use Japanese Crop Marks Japonca Kırpma İşaretlerini Kullan @@ -5402,7 +5408,7 @@ theme Appearance Görünüm @msg more than {MAX_CSV_ROWS} data sets: Import Data Sets reads at most {MAX_CSV_ROWS} {MAX_CSV_ROWS} veri setinden fazla: Veri Kümelerini İçe Aktar en fazla {MAX_CSV_ROWS} tane okur @msg more than {MAX_CSV_COLS} variables: Import Data Sets reads at most {MAX_CSV_COLS} {MAX_CSV_COLS} değişkenden fazla: Veri Kümelerini İçe Aktar en fazla {MAX_CSV_COLS} tane okur @msg x and y must be finite x ve y sonlu olmalıdır -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 eski .ai dosyalarını yazar; bir .ait şablonu her zaman PDF uyumludur +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 eski .ai dosyalarını yazar, .ait şablonlarını yazmaz @msg Edit Original opens image and document files only; “{name}” is not one Özgünü Düzenle yalnızca görüntü ve belge dosyalarını açar; “{name}” bunlardan biri değil @msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {pw} × {ph} pt sayfası için değil @msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {scale} ölçeğindeki {pw} × {ph} pt sayfası @@ -6131,6 +6137,7 @@ breakText Glyphs Glifler Gap 5 Boşluk 5 @msg {_1}: they came in as the page's art {_1}: sayfanın çizimi olarak içe aktarıldılar +@msg objects that drew differently from the file's own page came in as its art: {_1} dosyanın kendi sayfasından farklı çizilen nesneler sayfanın çizimi olarak içe aktarıldı: {_1} @msg You must choose a side pattern. Bir yan desen seçmelisiniz. @msg invalid pattern tile choice geçersiz desen döşemesi seçimi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 71d3310321..7d0aed742d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2128,6 +2128,11 @@ file-filter Templates Шаблони New Toolbar Нова панель інструментів Show Toolbar Показати панель інструментів List Toolbars Список панелей інструментів + Manage Toolbars… Керування панелями інструментів… + Manage Toolbars Керування панелями інструментів + Rename Toolbar Перейменувати панель інструментів + Duplicate Toolbar Дублювати панель інструментів + Delete Toolbar Видалити панель інструментів New {kind} Style… Новий стиль {kind}… New… Створити… Next Далі @@ -3610,6 +3615,7 @@ effect Points Точки Use Artboards Використовувати монтажні області Use Black Point Compensation Використовувати компенсацію чорної точки Use Compression Використовувати стиск + Include Editing Data for Other Apps Включати дані редагування для інших програм Use document bleed settings Використовувати параметри вильоту документа Use Even-Odd Fill Rule Використовувати правило заливки «парне–непарне» Use Japanese Crop Marks Використовувати японські мітки обрізання @@ -4022,7 +4028,7 @@ axis Both Обидві @msg Save for Web: the image is empty Зберегти для Інтернету: зображення пусте @msg Save writes {_1}, not {_2}: use document.export for other formats Збереження записує {_1}, а не {_2}: використовуйте document.export для інших форматів @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Збереження записує {_1}, а не {_2}: щоб записати його, використовуйте file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 записує застарілі файли .ai; шаблон .ait завжди сумісний із PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 записує застарілі файли .ai, а не шаблони .ait @msg Saved recovery data for {title} Збережено дані відновлення для {title} @msg Saved to User Defined: {_1} Збережено в «Користувацькі»: {_1} @msg Saved {path} Збережено: {path} @@ -5774,6 +5780,7 @@ breakText Glyphs Гліфи Gap 5 Проміжок 5 @msg {_1}: they came in as the page's art {_1}: їх імпортовано як графіку сторінки +@msg objects that drew differently from the file's own page came in as its art: {_1} об’єкти, що малювалися інакше, ніж власна сторінка файлу, імпортовано як графіку сторінки: {_1} @msg You must choose a side pattern. Виберіть бічний візерунок. @msg invalid pattern tile choice недійсний вибір плитки візерунка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8110a2d206..eddc7d5293 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2372,6 +2372,11 @@ digits Hindi Hindi New Toolbar Thanh công cụ mới Show Toolbar Hiện thanh công cụ List Toolbars Danh sách thanh công cụ + Manage Toolbars… Quản lý thanh công cụ… + Manage Toolbars Quản lý thanh công cụ + Rename Toolbar Đổi tên thanh công cụ + Duplicate Toolbar Nhân bản thanh công cụ + Delete Toolbar Xóa thanh công cụ New {kind} Style… Kiểu {kind} mới… New… Mới… Next Tiếp theo @@ -3985,6 +3990,7 @@ mark-style Japanese Kiểu Nhật Use Artboards Dùng bảng vẽ Use Black Point Compensation Dùng bù điểm đen Use Compression Dùng nén + Include Editing Data for Other Apps Bao gồm dữ liệu chỉnh sửa cho ứng dụng khác Use document bleed settings Dùng cài đặt bleed của tài liệu Use Even-Odd Fill Rule Dùng quy tắc tô chẵn-lẻ Use Japanese Crop Marks Dùng dấu cắt kiểu Nhật @@ -4757,7 +4763,7 @@ theme Appearance Giao diện @msg Save for Web: the image is empty Lưu cho Web: ảnh trống @msg Save writes {_1}, not {_2}: use document.export for other formats Lưu ghi {_1}, không phải {_2}: dùng document.export cho các định dạng khác @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Lưu ghi {_1}, không phải {_2}: dùng file.saveAsTemplate để ghi định dạng này -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 ghi tệp .ai cũ; mẫu .ait luôn tương thích PDF +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 ghi tệp .ai cũ, không ghi mẫu .ait @msg saved panel layout is invalid; reset the workspace first bố cục bảng đã lưu không hợp lệ; hãy đặt lại không gian làm việc trước @msg Saved recovery data for {title} Đã lưu dữ liệu phục hồi cho {title} @msg Saved to User Defined: {_1} Đã lưu vào Người dùng tùy chỉnh: {_1} @@ -5775,6 +5781,7 @@ breakText Glyphs Glyph Gap 5 Khoảng hở 5 @msg {_1}: they came in as the page's art {_1}: đã được nhập dưới dạng hình vẽ của trang +@msg objects that drew differently from the file's own page came in as its art: {_1} các đối tượng được vẽ khác với trang riêng của tệp đã được nhập dưới dạng hình vẽ của trang: {_1} @msg You must choose a side pattern. Bạn phải chọn mẫu bên. @msg invalid pattern tile choice lựa chọn ô mẫu không hợp lệ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index aaf791fea2..72c26ec252 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2006,6 +2006,11 @@ file-filter Templates 模板 New Toolbar 新建工具栏 Show Toolbar 显示工具栏 List Toolbars 列出工具栏 + Manage Toolbars… 管理工具栏… + Manage Toolbars 管理工具栏 + Rename Toolbar 重命名工具栏 + Duplicate Toolbar 复制工具栏 + Delete Toolbar 删除工具栏 New {kind} Style… 新建{kind}样式… New… 新建… Next 下一个 @@ -3452,6 +3457,7 @@ digits Malayalam 马拉雅拉姆数字 Use Artboards 使用画板 Use Black Point Compensation 使用黑场补偿 Use Compression 使用压缩 + Include Editing Data for Other Apps 包含供其他应用使用的编辑数据 Use document bleed settings 使用文档的出血设置 Use Even-Odd Fill Rule 使用奇偶填色规则 Use Japanese Crop Marks 使用日式裁切标记 @@ -5434,7 +5440,7 @@ lighting Light 光源 Enable Guides for 9-Slice Scaling 启用 9-slice 缩放参考线 @msg cancelled; nothing was changed 已取消;未做任何更改 @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it 保存会写入 {_1},而不是 {_2}:要写入该格式请使用 file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 写入旧版 .ai 文件;.ait 模板始终与 PDF 兼容 +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 写入旧版 .ai 文件,不写入 .ait 模板 @msg a mask has more than one art block 蒙版包含多个图稿块 @msg mask art ends outside a mask dictionary 蒙版图稿在蒙版字典之外结束 @msg `{key}` must be a #rrggbb color `{key}` 必须是 #rrggbb 格式的颜色 @@ -6015,6 +6021,7 @@ breakText Glyphs 字形 Gap 5 间隔 5 @msg {_1}: they came in as the page's art {_1}:已作为页面图稿导入 +@msg objects that drew differently from the file's own page came in as its art: {_1} 与文件自身页面绘制不同的对象已作为页面图稿导入:{_1} @msg You must choose a side pattern. 必须选择侧边图案。 @msg invalid pattern tile choice 图案拼块选项无效 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a0bf8bf80b..4b9a52bf0b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2496,7 +2496,7 @@ file-filter Templates 範本 @msg the action has no steps 動作沒有步驟 @msg cancelled; nothing was changed 已取消;未做任何變更 @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it 「儲存」會寫入 {_1},而非 {_2}:若要寫入該格式,請使用 file.saveAsTemplate -@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 會寫入舊版 .ai 檔案;.ait 範本一律與 PDF 相容 +@msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 會寫入舊版 .ai 檔案,不會寫入 .ait 範本 @msg a mask has more than one art block 遮色片含有多個圖稿區塊 @msg mask art ends outside a mask dictionary 遮色片圖稿在遮色片字典之外結束 @msg `{key}` must be a #rrggbb color `{key}` 必須是 #rrggbb 格式的顏色 @@ -2983,6 +2983,11 @@ file-filter Templates 範本 New Toolbar 新增工具列 Show Toolbar 顯示工具列 List Toolbars 列出工具列 + Manage Toolbars… 管理工具列… + Manage Toolbars 管理工具列 + Rename Toolbar 重新命名工具列 + Duplicate Toolbar 製作工具列複本 + Delete Toolbar 刪除工具列 New {kind} Style… 新增{kind}樣式… New… 新增… Next 下一個 @@ -4474,6 +4479,7 @@ digits Malayalam 馬來亞拉姆數字 Use Artboards 使用工作區域 Use Black Point Compensation 使用黑點補償 Use Compression 使用壓縮 + Include Editing Data for Other Apps 包含供其他應用程式使用的編輯資料 Use document bleed settings 使用文件的出血設定 Use Even-Odd Fill Rule 使用奇偶填色規則 Use Japanese Crop Marks 使用日式裁切標記 @@ -5812,6 +5818,7 @@ breakText Glyphs 字符 Gap 5 間隙 5 @msg {_1}: they came in as the page's art {_1}:已作為頁面圖稿匯入 +@msg objects that drew differently from the file's own page came in as its art: {_1} 與檔案本身頁面繪製不同的物件已作為頁面圖稿匯入:{_1} @msg You must choose a side pattern. 必須選擇側邊圖樣。 @msg invalid pattern tile choice 圖樣拼塊選項無效 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index ace4f2694e..d9d2b470a0 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -247,6 +247,8 @@ fn try_activate_open_document(app: &mut VectorcraftApp, path: &str) -> Result Result /// [`open_path`]; with `background`, a document (not a file that installs, imports or asks /// first) is decoded off the UI thread ([`crate::opening::start`]). pub(crate) fn open_file(app: &mut VectorcraftApp, path: &str, background: bool) -> Result { + // Automation confined to some folders reaches no file outside them, open in a tab or not. + vectorcraft_engine::file_access::check_read(path)?; // File › Open, Open Recent, the Home screen, a drop and the Finder all focus a document that - // is already open, or still being read, instead of opening a second copy. + // is already open, or still being read, instead of opening a second copy. While a dialog or + // a drag acts on the active document, its tab comes to the front once that ends + // ([`crate::opening::poll`]), as a file that finishes opening then does. + if document_index_for_path(app, path).is_some() && !crate::opening::may_switch(app) { + if !app.opening.activate.iter().any(|(p, _)| same_open_path(p, path)) { + let scope = vectorcraft_engine::file_access::ScopeHandle::capture(); + app.opening.activate.push((path.to_string(), scope)); + } + return Ok(Value::Null); + } if let Some(r) = try_activate_open_document(app, path)? { return Ok(r); } diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 5661450266..1666e147ec 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -544,7 +544,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("brightness must be dark|mediumDark|mediumLight|light".into()), }, - "window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()), + "window.newWindow" => crate::doc_windows::new_window(app), "window.arrange.cascade" => crate::doc_windows::cascade(app), "window.arrange.tile" => crate::canvas::tile(app), "window.arrange.floatInWindow" => crate::doc_windows::float_in_window(app, p), @@ -609,10 +609,15 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?; - crate::unsaved::close(app, i) - } + // Without an `index`, the current window closes: one of several views of a document closes + // without closing the document (Window › New Window). + "file.close" => match p.get("index").and_then(Value::as_u64).map(|i| i as usize) { + Some(i) => crate::unsaved::close(app, i), + None => { + app.session.active_index()?; + crate::doc_windows::close_current(app) + } + }, "file.closeAll" => crate::unsaved::close_all(app, "closeAll"), "app.quit" => crate::unsaved::close_all(app, "quit"), "ui.swatchOptions" => match s("name") { @@ -818,6 +823,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::new_action(app, p), crate::panels::actions::ACTION_OPTIONS => crate::panels::actions::action_options(app, p), crate::panels::actions::PLAY_ACTION => crate::panels::actions::play_action(app, p), + crate::panels::actions::START_RECORDING => crate::panels::actions::record(app, p), crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p), "ui.batchDialog" => { let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 523d930e0d..2dd1051b12 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -283,17 +283,16 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() } "ui.perspectivePlane" => app.session.active().is_some(), - // Only one window per document so far: grayed out (#3724). - "window.newWindow" => false, - // Tile and Cascade with two documents or more; Float in Window for a current document under - // the tabs; Float All in Windows while a document is under the tabs; Consolidate All - // Windows while documents are tiled or floating. - "window.arrange.tile" | "window.arrange.cascade" => app.session.documents().len() > 1, - "window.arrange.floatInWindow" => app.session.active_index().is_some_and(|i| !crate::doc_windows::is_floating(app, i)), - "window.arrange.floatAllInWindows" => !crate::doc_windows::docked(app).is_empty(), + "window.newWindow" => app.session.active().is_some(), + // Tile and Cascade with two windows or more (documents, or views of one from New Window); + // Float in Window for a current window under the tabs; Float All in Windows while a window + // is under the tabs; Consolidate All Windows while windows are tiled or floating. + "window.arrange.tile" | "window.arrange.cascade" => crate::doc_windows::windows(app).len() > 1, + "window.arrange.floatInWindow" => crate::doc_windows::current(app).is_some_and(|w| !crate::doc_windows::floats(app, w)), + "window.arrange.floatAllInWindows" => !crate::doc_windows::docked_windows(app).is_empty(), "window.arrange.consolidateAllWindows" => { - let n = app.session.documents().len(); - n > 0 && (app.ui.tiled || crate::doc_windows::docked(app).len() < n) + let n = crate::doc_windows::windows(app).len(); + n > 0 && (app.ui.tiled || crate::doc_windows::docked_windows(app).len() < n) } "window.nextDocument" | "window.previousDocument" => !app.session.documents().is_empty(), _ => true, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 4b46453ca7..3918b9fb1c 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -912,15 +912,17 @@ fn unlock_all_and_show_all_clicked_with_shift_alt_act_within_the_group() { } } -/// #3724: Window › New Window is greyed out until VectorCraft can open a second window on a -/// document, instead of being enabled and answering with an internal milestone note. +/// #4116: Window › New Window is enabled with a document open and opens a second view of it; with +/// no document open it is greyed out. #[test] -fn new_window_is_disabled_until_it_opens_a_window() { +fn new_window_opens_a_view_of_the_current_document() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + assert!(!enabled(&app, "window.newWindow"), "no document to open a window on"); + assert_eq!(app.run("window.newWindow", json!({})).unwrap_err(), "no document open"); app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); - assert!(!enabled(&app, "window.newWindow"), "New Window is enabled but opens no window"); - let e = app.run("window.newWindow", json!({})).unwrap_err(); - assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}"); + assert!(enabled(&app, "window.newWindow")); + assert_eq!(app.run("window.newWindow", json!({})).unwrap(), json!({"index": 0, "view": 2})); + assert_eq!(app.session.documents().len(), 1, "the same document, in a second window"); } /// #4143: Window menu panels and panel submenus are listed in alphabetical order according to the diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 9e887870a1..92bd0ed742 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -497,6 +497,30 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name: string, show?: bool} show (true), hide (false) or toggle (omitted) the toolbar made with New Toolbar called `name`, as its item under Window › Toolbars does (its title bar's × hides it); shown after Tab hid the toolbars, it shows alone and the others are turned off; returns the new state", ), + ( + "window.toolbar.manage", + "Manage Toolbars…", + "", + "{} open the Manage Toolbars dialog, which lists the toolbars made with New Toolbar: a name typed for the selected one renames it, + copies it (none selected: makes a blank toolbar) and the trash can deletes it", + ), + ( + "window.toolbar.rename", + "Rename Toolbar", + "", + "{name: string, to: string} give the toolbar made with New Toolbar called `name` the name `to` (not empty, at most 200 characters, not another toolbar's whatever the case), as typing a name in Manage Toolbars does; returns {toolbar: name}", + ), + ( + "window.toolbar.duplicate", + "Duplicate Toolbar", + "", + "{name?: string} copy the toolbar made with New Toolbar called `name`, with its tools, as \"`name` Copy\" (omitted: make a blank toolbar called \"Toolbar N\"), as Manage Toolbars' + does; the copy shows and is kept with the current workspace; returns {toolbar: name}", + ), + ( + "window.toolbar.delete", + "Delete Toolbar", + "", + "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", + ), ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( @@ -580,7 +604,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.workspace.delete", "Delete Workspace", "", "{name}"), ("window.workspace.rename", "Rename Workspace", "", "{name, to}"), ("window.workspace.list", "List Workspaces", "", "{}"), - ("window.newWindow", "New Window", "", "{}"), + ( + "window.newWindow", + "New Window", + "", + "{} open another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own (titled with the view's number, \"Name:2\") or, when the current view floats, in a floating window; it becomes the current window; the Arrange commands treat each view as a window; file.close closes the current view and the last view closes the document; returns {index, view}", + ), ( "window.arrange.cascade", "Cascade", @@ -591,13 +620,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.arrange.tile", "Tile", "", - "{} show every open document at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of documents, rounded up), each fitted to its pane; the current document's pane takes the tools, a click in another pane makes its document the current one; the tabs stay; documents in floating windows come back under the tabs first; with fewer than two documents open the view stays as it is; returns {tiled, panes}", + "{} show every open document and each further view of one (window.newWindow) at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of panes, rounded up), each fitted to its pane; the current window's pane takes the tools, a click in another pane makes its window the current one; the tabs stay; windows floating come back under the tabs first; with fewer than two windows open the view stays as it is; returns {tiled, panes}", ), ( "window.arrange.floatInWindow", "Float in Window", "", - "{index?, at?: [x, y]} move the current document, or document `index` (which becomes the current one), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)", + "{index?, view?, at?: [x, y]} move the current window, or document `index`'s view `view` (its current view without one; it becomes the current window), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)", ), ( "window.arrange.floatAllInWindows", @@ -609,19 +638,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.arrange.consolidateAllWindows", "Consolidate All Windows", "", - "{index?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)", + "{index?, view?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` (its view `view`, else its current view) goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)", ), ( "window.nextDocument", "Next Document", "Cmd+F6", - "{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); returns {index}", + "{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), ( "window.previousDocument", "Previous Document", "Cmd+Shift+F6", - "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); returns {index}", + "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), ( @@ -818,7 +847,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.layerOptions", "Options for Selection…", "", - "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step)", + "{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog", ), ( "ui.newLayer", @@ -1200,6 +1229,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{action: name, set?: name} play the Actions panel's action named action as one undoable batch; set names the set to look in (default: every set). A name no action has, or one that more than one action in the sets looked in has, is an error and nothing plays → what command.batch returns", ), + ( + crate::panels::actions::START_RECORDING, + "Begin Recording", + "", + "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}", + ), ( crate::panels::actions::STOP_RECORDING, "Stop Playing/Recording", diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 6de2fe0f74..fb2949dd49 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -29,6 +29,9 @@ pub struct Pending { #[derive(Default)] pub struct Opening { pub pending: Vec, + /// Open documents asked for again while the active one couldn't change: their tabs come to + /// the front once it can ([`may_switch`]), each in the file access scope it was asked in. + pub activate: Vec<(String, ScopeHandle)>, } /// Is a long operation running that should show the busy cursor and status spinner (background @@ -84,6 +87,12 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result bool { + let s = &app.session; + !(app.ui.dialog.is_some() || s.in_interaction() || (s.tool_busy() && s.pointer_held())) +} + /// Open the files that are ready (each frame). Not while a dialog is open: a document that opens /// becomes the active one, and the dialog's OK acts on the active document (#1694: Save As /// options confirmed after another file had finished opening saved that file's art under the @@ -94,9 +103,18 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result Result s.actions.push(action); let index = s.actions.len() - 1; start_recording(app, set, name, Some(id)); - app.ui.action_set = Some(set); - app.ui.action_sets_selected = vec![set]; + if app.ui.action_set != Some(set) { + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; + } if let Some(ctx) = app.ui_ctx.clone() { set_pstate::>(&ctx, "actions-selected", Some((set, index))); } @@ -328,6 +330,40 @@ pub fn play_action(app: &mut VectorcraftApp, p: &Value) -> Result /// The command that ends a recording ([`stop`]). pub const STOP_RECORDING: &str = "ui.action.stop"; +/// The command that begins a plain recording ([`record`]). +pub const START_RECORDING: &str = "ui.action.record"; + +/// `ui.action.record {set?}`: begins recording an automatically named Action N in `set`, or in +/// the current set (the first if none is current). With no sets, makes Set 1. Stop adds the action +/// after the set's existing actions. An explicit invalid set or an existing recording is an error +/// and leaves the panel unchanged. → {set, name} +pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { + if app.ui.recording.is_some() { + return Err("an action is already recording".into()); + } + let set = match p.get("set") { + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < app.ui.action_sets.len()).ok_or("no such action set")?, + _ => { + if app.ui.action_sets.is_empty() { + app.ui.action_sets.push(ActionSet::new("Set 1", vec![])); + } + app.ui.action_set.filter(|&i| i < app.ui.action_sets.len()).unwrap_or(0) + } + }; + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; + let mut number = app.ui.action_sets[set].actions.len() + 1; + let name = loop { + let name = format!("Action {number}"); + if app.ui.action_sets[set].actions.iter().all(|action| action.name != name) { + break name; + } + number += 1; + }; + start_recording(app, set, name.clone(), None); + Ok(json!({ "set": set, "name": name })) +} + /// `ui.action.stop {}`: ends the recording in progress and keeps its steps, in the action New /// Action made for it, else in a new action after the last action of its set, as the bottom bar's /// Stop Playing/Recording does. With nothing recording it changes nothing. → {stopped} @@ -786,19 +822,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); ui.painter().circle_filled(r.center(), 6.0, if recording { egui::Color32::from_rgb(0xe0, 0x30, 0x30) } else { t.icon }); - if resp.on_hover_text(tl!("Begin Recording")).clicked() && !recording { - if app.ui.action_sets.is_empty() { - app.ui.action_sets.push(ActionSet::new("Set 1", vec![])); - } - let set = current.filter(|&s| s < app.ui.action_sets.len()).unwrap_or(0); - // With no set current, the recording goes into the first set, which becomes current so - // the panel shows where the recording goes. - if app.ui.action_set != Some(set) { - app.ui.action_set = Some(set); - app.ui.action_sets_selected = vec![set]; - } - let n = app.ui.action_sets.get(set).map_or(0, |s| s.actions.len()) + 1; - start_recording(app, set, format!("Action {n}"), None); + if resp.on_hover_text(tl!("Begin Recording")).clicked() + && !recording + && let Err(e) = app.run(START_RECORDING, json!({})) + { + app.status(e); } if widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked() && let Some(s) = selected @@ -847,6 +875,28 @@ mod tests { } } + #[test] + fn begin_recording_command_matches_the_panel_button() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.ui.action_sets.clear(); + app.ui.action_set = None; + assert_eq!(app.run(START_RECORDING, json!({})).unwrap(), json!({"set": 0, "name": "Action 1"})); + assert_eq!((app.ui.action_sets[0].name.as_str(), app.ui.action_set), ("Set 1", Some(0))); + assert_eq!(app.run(START_RECORDING, json!({})).unwrap_err(), "an action is already recording"); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + assert_eq!(app.run(STOP_RECORDING, json!({})).unwrap(), json!({"stopped": true})); + assert_eq!((app.ui.action_sets[0].actions[0].name.as_str(), app.ui.action_sets[0].actions[0].steps.len()), ("Action 1", 1)); + + app.ui.action_sets.push(ActionSet::new("Other", vec![])); + assert_eq!(app.run(START_RECORDING, json!({"set": 99})).unwrap_err(), "no such action set"); + assert_eq!(app.run(START_RECORDING, json!({"set": null})).unwrap_err(), "no such action set"); + assert!(app.ui.recording.is_none()); + assert_eq!(app.run(START_RECORDING, json!({"set": 1})).unwrap(), json!({"set": 1, "name": "Action 1"})); + assert_eq!((app.ui.action_set, app.ui.action_sets_selected.as_slice()), (Some(1), &[1][..])); + assert_eq!(app.run(STOP_RECORDING, json!({})).unwrap(), json!({"stopped": true})); + } + /// #1629: a clicked action stays selected, so Play Current Selection plays it and Delete /// Selection deletes it. #[test] diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index dc56d99e8a..ba4eebddab 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -870,6 +870,164 @@ fn transform_effect_name_edits_the_displayed_appearance_target() { } } +#[test] +fn transform_effect_dialog_reads_the_selected_compound_members_owner() { + for item in [None, Some(0)] { + let mut app = app_with_rect(); + let member = app.session.doc().unwrap().selection.subjects()[0]; + let other = app.run("shape.rectangle", json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [member.0, other]})).unwrap(); + let compound = app.run("object.compoundPath.make", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": item, "params": {"moveH": 12, "copies": 2}})).unwrap(); + app.run("select.set", json!({"ids": [member.0]})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.transform", "item": item, "index": 0})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.f64("moveH", 0.0), 12.0); + assert_eq!(d.f64("copies", 0.0), 2.0); + d.fields.insert("moveH".into(), json!(35)); + crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::dialogs::confirm(&mut app).unwrap(); + let effects = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(compound)).unwrap().appearance; + assert_eq!(effects.effects_at(item).unwrap()[0].params["moveH"].as_f64(), Some(35.0)); + assert_eq!(effects.effects_at(item).unwrap()[0].params["copies"].as_f64(), Some(2.0)); + app.run("edit.undo", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.transform", "item": item})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "effectExists"); + crate::dialogs::confirm(&mut app).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.f64("moveH", 0.0), 12.0); + d.fields.insert("moveH".into(), json!(60)); + crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::dialogs::cancel(&mut app); + let effects = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(compound)).unwrap().appearance; + assert_eq!(effects.effects_at(item).unwrap()[0].params["moveH"].as_f64(), Some(12.0)); + } +} + +#[test] +fn transform_effect_dialog_keeps_the_active_items_selection_anchor() { + for item in [None, Some(json!(0)), Some(Value::Null)] { + let mut app = app_with_rect(); + let back = app.session.doc().unwrap().selection.subjects()[0]; + for at in [json!(0), Value::Null] { + app.run("effect.apply", json!({"effect": "distort.transform", "item": at, "params": {"moveH": 99}})).unwrap(); + } + let front = app.run("shape.rectangle", json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap()["id"].clone(); + for at in [json!(0), Value::Null] { + app.run("effect.apply", json!({"effect": "distort.transform", "item": at, "params": {"moveH": 12}})).unwrap(); + } + app.run("select.set", json!({"ids": [front, back.0]})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": 0})).unwrap(); + let mut p = json!({"effect": "distort.transform"}); + if let Some(item) = &item { + p["item"] = item.clone(); + } + app.run("effect.dialog", p).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "effectExists"); + crate::dialogs::confirm(&mut app).unwrap(); + let expected = if item.is_none() { 12.0 } else { 99.0 }; + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("moveH", 0.0), expected); + } +} + +#[test] +fn explicit_effect_items_use_paint_order_with_a_selected_compound_member() { + let mut app = app_with_rect(); + let back = app.session.doc().unwrap().selection.subjects()[0]; + for item in [json!(0), Value::Null] { + app.run("effect.apply", json!({"effect": "distort.transform", "item": item, "params": {"moveH": 99}})).unwrap(); + } + let member = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 80})).unwrap()["id"].clone(); + let inner = app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [member, inner]})).unwrap(); + app.run("object.compoundPath.make", json!({})).unwrap(); + for item in [json!(0), Value::Null] { + app.run("effect.apply", json!({"effect": "distort.transform", "item": item, "params": {"moveH": 12}})).unwrap(); + } + app.run("select.set", json!({"ids": [member, back.0]})).unwrap(); + for item in [json!(0), Value::Null] { + app.run("effect.dialog", json!({"effect": "distort.transform", "item": item, "index": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("moveH", 0.0), 99.0); + crate::dialogs::cancel(&mut app); + } +} + +#[test] +fn editing_an_active_effect_keeps_aligned_rows_in_mixed_stacks() { + let mut app = app_with_rect(); + let back = app.session.doc().unwrap().selection.subjects()[0]; + app.run("appearance.addStroke", json!({})).unwrap(); + app.run("appearance.moveItem", json!({"from": 2, "to": 0})).unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": 0, "params": {"moveH": 99}})).unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": 1, "params": {"moveH": 12}})).unwrap(); + let front = app.run("shape.rectangle", json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": 0, "params": {"moveH": 12}})).unwrap(); + app.run("select.set", json!({"ids": [front, back.0]})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": 0})).unwrap(); + app.run("effect.dialog", json!({"effect": "distort.transform", "index": 0})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.f64("moveH", 0.0), 12.0); + d.fields.insert("moveH".into(), json!(35)); + app.run("appearance.setActiveItem", json!({"index": null})).unwrap(); + crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + let front = &doc.node(vectorcraft_doc::NodeId(front)).unwrap().appearance; + let back = &doc.node(back).unwrap().appearance; + assert_eq!(front.effects_at(Some(0)).unwrap()[0].params["moveH"], 35); + assert_eq!(back.effects_at(Some(1)).unwrap()[0].params["moveH"], 35); + assert_eq!(back.effects_at(Some(0)).unwrap()[0].params["moveH"], 99); +} + +#[test] +fn editing_an_effect_keeps_its_original_item_when_the_active_row_changes() { + for prompt in [false, true] { + let mut app = app_with_rect(); + let id = app.session.doc().unwrap().selection.subjects()[0]; + app.run("effect.apply", json!({"effect": "distort.transform", "item": null, "params": {"moveH": 99}})).unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": 0, "params": {"moveH": 12}})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": 0})).unwrap(); + let mut p = json!({"effect": "distort.transform"}); + if !prompt { + p["index"] = json!(0); + } + app.run("effect.dialog", p).unwrap(); + app.run("appearance.setActiveItem", json!({"index": null})).unwrap(); + if prompt { + crate::dialogs::confirm(&mut app).unwrap(); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.f64("moveH", 0.0), 12.0); + d.fields.insert("moveH".into(), json!(35)); + crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx())); + crate::dialogs::confirm(&mut app).unwrap(); + let appearance = &app.session.doc().unwrap().doc.node(id).unwrap().appearance; + assert_eq!(appearance.effects_at(Some(0)).unwrap()[0].params["moveH"].as_f64(), Some(35.0)); + assert_eq!(appearance.effects[0].params["moveH"].as_f64(), Some(99.0)); + } +} + +#[test] +fn new_effects_keep_implicit_item_targeting_until_confirmation() { + for prompt in [false, true] { + let mut app = app_with_rect(); + let id = app.session.doc().unwrap().selection.subjects()[0]; + app.run("effect.apply", json!({"effect": "distort.transform", "item": null, "params": {"moveH": 99}})).unwrap(); + app.run("effect.apply", json!({"effect": "distort.transform", "item": 0, "params": {"moveH": 12}})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": 0})).unwrap(); + app.run("effect.dialog", json!({"effect": if prompt { "distort.transform" } else { "distort.roughen" }})).unwrap(); + app.run("appearance.setActiveItem", json!({"index": null})).unwrap(); + if prompt { + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + } + crate::dialogs::confirm(&mut app).unwrap(); + let appearance = &app.session.doc().unwrap().doc.node(id).unwrap().appearance; + assert_eq!(appearance.effects_at(Some(0)).unwrap().len(), 1); + assert_eq!(appearance.effects.len(), 2); + } +} + #[test] fn transform_effect_typed_scale_changes_the_painted_object() { for layer_target in [false, true] { diff --git a/crates/ui-egui/src/panels/tests_effectedit.rs b/crates/ui-egui/src/panels/tests_effectedit.rs index a27a60cb62..48dfdfc25f 100644 --- a/crates/ui-egui/src/panels/tests_effectedit.rs +++ b/crates/ui-egui/src/panels/tests_effectedit.rs @@ -59,7 +59,10 @@ fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() { let texts = frame_events(&ctx, &mut app, vec![], appearance::show); click(&ctx, &mut app, text_rect(&texts, "Roughen").center(), appearance::show); let d = app.ui.dialog.clone().expect("the effect's dialog"); - assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone()), ("effect", 9.0, json!(0))); + assert_eq!( + (d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone(), d.fields["__item"].clone()), + ("effect", 9.0, json!(0), Value::Null) + ); // The dialog previews; OK keeps the new value on the same effect as one undo step. let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); out.textures_delta.clear(); @@ -115,9 +118,10 @@ fn choosing_an_applied_effect_asks_to_edit_it_or_add_another() { assert_eq!(app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(), json!({"pending": "effectExists"})); crate::dialogs::confirm(&mut app).unwrap(); let d = app.ui.dialog.clone().unwrap(); - assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone(), d.fields["__item"].clone()), ("effect", 9.0, json!(0), json!(0))); + assert_eq!((d.kind.as_str(), d.f64("size", 0.0), d.fields["__index"].clone()), ("effect", 9.0, json!(0))); crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(effects(&app, Some(0)).len(), 1); + assert!(effects(&app, None).is_empty()); // "Add New": a fresh dialog with the defaults, which adds a second one. app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4fc10697f6..294b5bb4d2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -468,10 +468,10 @@ pub struct UiState { /// saved, as `tiled` is not. #[serde(skip)] pub floating: Vec, - /// The document (its uid) the tab strip shows while the current document is in a floating - /// window: the tabbed document that was current last. + /// The window (its document's uid and its view's number) the tab strip shows while the current + /// window is a floating one: the tabbed window that was current last. #[serde(skip)] - pub tab_document: Option, + pub tab_document: Option<(u64, u32)>, /// Draw Normal / Behind / Inside. pub draw_mode: u8, pub about: bool, diff --git a/crates/ui-egui/src/tests/document_drag.rs b/crates/ui-egui/src/tests/document_drag.rs index 7001e13be9..b5727eaada 100644 --- a/crates/ui-egui/src/tests/document_drag.rs +++ b/crates/ui-egui/src/tests/document_drag.rs @@ -361,3 +361,38 @@ fn scrolling_tabs_under_a_stationary_drag_does_not_cascade_to_other_documents() frame(&mut app, &ctx, 2.3, vec![button(drop, false)]); assert_eq!(app.session.active().unwrap().selection.objects.len(), 1); } + +/// Artwork held over the tab of another view of its own document (Window › New Window) switches to +/// that view, and a drop there moves it. +#[test] +fn dragging_artwork_over_another_views_tab_switches_to_that_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300, "name": "Logo"})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("window.newWindow", json!({})).unwrap(); + crate::doc_windows::show_window(&mut app, (0, 1)); + app.select_tool("selection"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, 0.0, vec![]); + let tabs = frame(&mut app, &ctx, 0.01, vec![]); + assert_eq!(tabs.len(), 2); + let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.0, 100.0)); + frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]); + frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]); + assert!(app.session.in_interaction()); + frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]); + frame(&mut app, &ctx, 0.25, vec![]); + frame(&mut app, &ctx, 0.8, vec![]); + assert_eq!(crate::doc_windows::current(&app), Some((0, 2)), "holding the artwork over the other view's tab shows that view"); + assert!(!app.session.tool_busy(), "the switch resets the Selection tool before the drop"); + let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(200.0, 150.0)); + frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]); + frame(&mut app, &ctx, 1.0, vec![button(drop, false)]); + let st = app.session.active().unwrap(); + let moved = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert!((moved.center().x - 200.0).abs() < 0.01 && (moved.center().y - 150.0).abs() < 0.01, "{moved:?}"); + assert!(!app.session.tool_busy(), "the move leaves the Selection tool idle"); +} diff --git a/crates/ui-egui/src/tests/home.rs b/crates/ui-egui/src/tests/home.rs index 4f5fc2b3c8..96f50716db 100644 --- a/crates/ui-egui/src/tests/home.rs +++ b/crates/ui-egui/src/tests/home.rs @@ -349,3 +349,92 @@ fn reopening_an_open_file_focuses_its_tab() { assert_eq!(focused(&app, "drop"), uid); let _ = std::fs::remove_dir_all(d); } + +/// An app with `doc.vectorcraft` saved in a fresh folder `name`, open in the first tab, and an +/// untitled document active in the second → (app, folder, document path). +fn saved_and_another(name: &str) -> (VectorcraftApp, std::path::PathBuf, String) { + let services = crate::Services { + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services); + let d = std::env::temp_dir().join(format!("vc-reopen-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let path = d.join("doc.vectorcraft").to_string_lossy().to_string(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("file.saveAs", json!({"path": path})).unwrap(); + app.run("file.new", json!({})).unwrap(); + assert_eq!((app.session.documents().len(), app.session.active_index()), (2, Some(1))); + (app, d, path) +} + +/// Home shown over the document asked for gives way to it, as it does to any other tab (#5369). +#[test] +fn reopening_the_active_document_closes_home() { + let (mut app, d, path) = saved_and_another("home"); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run("app.home", json!({})).unwrap(); + assert!(crate::menus::home_showing(&app)); + assert_eq!(crate::io::open_reporting(&mut app, &path).unwrap()["activated"], true); + assert!(!crate::menus::home_showing(&app)); + let _ = std::fs::remove_dir_all(d); +} + +/// Automation confined to a folder can't reach an open document saved outside it (#5369). +#[test] +fn a_confined_open_does_not_reach_an_open_document_outside_its_roots() { + use vectorcraft_engine::file_access; + let (mut app, d, path) = saved_and_another("roots"); + let inside = d.join("inside"); + std::fs::create_dir_all(&inside).unwrap(); + let roots = file_access::AutomationRoots::new(&[inside.as_path()], &[]).unwrap(); + assert!(file_access::confine(roots.as_ref(), || app.run("file.open", json!({"path": path}))).is_err()); + assert_eq!((app.session.documents().len(), app.session.active_index()), (2, Some(1))); + let _ = std::fs::remove_dir_all(d); +} + +/// While a dialog acts on the active document, an open document asked for again comes to the +/// front only once the dialog closes, so the dialog's OK acts on its own document (#5369). +#[test] +fn reopening_while_a_dialog_is_open_waits_for_it_to_close() { + let (mut app, d, path) = saved_and_another("dialog"); + app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({}))); + assert_eq!(crate::io::open_reporting(&mut app, &path).unwrap(), serde_json::Value::Null); + crate::opening::poll(&mut app); + assert_eq!((app.session.documents().len(), app.session.active_index()), (2, Some(1)), "still the dialog's document"); + app.ui.dialog = None; + crate::opening::poll(&mut app); + assert_eq!((app.session.documents().len(), app.session.active_index()), (2, Some(0))); + let _ = std::fs::remove_dir_all(d); +} + +/// A document asked for under automation roots while a dialog is open comes to the front, or +/// opens again, in those roots once the dialog closes, not in the scope of the frame that runs it +/// (#5369): closed meanwhile and now a link to a file outside the roots, it is not read. +#[cfg(unix)] +#[test] +fn a_queued_reopen_keeps_the_roots_it_was_asked_in() { + use vectorcraft_engine::file_access; + let (mut app, d, _) = saved_and_another("queued-roots"); + let (inside, outside) = (d.join("inside"), d.join("outside")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let path = inside.join("doc.vectorcraft").to_string_lossy().to_string(); + app.run("document.activate", json!({"index": 0})).unwrap(); + app.run("file.saveAs", json!({"path": path})).unwrap(); + app.run("document.activate", json!({"index": 1})).unwrap(); + app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({}))); + let roots = file_access::AutomationRoots::new(&[inside.as_path()], &[]).unwrap(); + assert_eq!(file_access::confine(roots.as_ref(), || crate::io::open_reporting(&mut app, &path)).unwrap(), serde_json::Value::Null); + // Closed, and its file swapped for a link to one outside the roots. + app.session.execute("file.close", &json!({"index": 0})).unwrap(); + let away = outside.join("doc.vectorcraft"); + std::fs::rename(&path, &away).unwrap(); + std::os::unix::fs::symlink(&away, &path).unwrap(); + app.ui.dialog = None; + crate::opening::poll(&mut app); + assert_eq!(app.session.documents().len(), 1, "the file outside the roots is not opened"); + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index 574bb07b51..65e7f57624 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -640,6 +640,21 @@ fn nothing_closes_or_quits_while_a_sheet_is_open() { assert_eq!(app.ui.status, "quit"); } +/// A view Window › New Window opened does not close while a dialog's sheet is on the window either. +#[test] +fn no_view_closes_while_a_sheet_is_open() { + let (mut app, shown, _) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("window.newWindow", json!({})).unwrap(); + assert!(app.run("file.open", json!({})).is_err()); + assert_eq!(app.run("file.close", json!({})), Err(picks::busy().to_string())); + assert_eq!(crate::doc_windows::windows(&app), [(0, 1), (0, 2)]); + shown.answer(&[]); + poll(&mut app); + app.run("file.close", json!({})).unwrap(); + assert_eq!(crate::doc_windows::windows(&app), [(0, 1)]); +} + /// On Linux the dialog is a window of its own, not a sheet: Close and Quit go on while it is open, /// and its answer for the document closed meanwhile is dropped, nothing written. #[test] diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 7a6bd26168..a1374bd373 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -1,7 +1,7 @@ //! The Contextual Task Bar's actions for each kind of selection: open paths offer Join and Edit //! Path, compound paths and clipping masks their Release, images selected with vector art Make -//! Clipping Mask, type converts between point and area type, and a basic shape shows its -//! properties in place of Lock. +//! Clipping Mask, type converts between point and area type, type on a path opens its options, and +//! a basic shape shows its properties in place of Lock. use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::json; @@ -165,6 +165,25 @@ fn type_converts_between_point_and_area_type() { assert!(matches!(text_kind(&app), TextKind::Point)); } +#[test] +fn type_on_a_path_offers_its_options() { + let mut app = app(); + let path = id(app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()); + let t = id(app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [200, 160]})).unwrap()); + assert!(matches!(kind(&app, t), NodeKind::Text(ref t) if matches!(t.kind, TextKind::OnPath { .. }))); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + // A non-default effect, so the dialog shows the selected type's options rather than defaults. + app.run("type.pathOptions", json!({"effect": "skew"})).unwrap(); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Create Outlines") && has(&texts, "Type on a Path Options"), "{texts:?}"); + assert!(!has(&texts, "Convert To Area Type") && !has(&texts, "Convert To Point Type"), "{texts:?}"); + click(&mut app, "Type on a Path Options"); + let d = app.ui.dialog.as_ref().expect("the options dialog opens"); + assert_eq!(d.str("__command"), "type.pathOptions"); + assert_eq!(d.fields.get("effect"), Some(&json!("skew"))); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 4518dcb7fb..01eafaed9f 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -19,7 +19,7 @@ pub const KIND: &str = "saveChanges"; /// Nothing closes while a file dialog's sheet is on the window (macOS): what asked for it runs /// again when it answers. (Elsewhere the dialog is a window of its own, and closing goes on.) -fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> { +pub(crate) fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> { if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cdb7471df1..b3555ec96a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -382,6 +382,16 @@ two-column flag, top-left corner, the groups torn off it). They are kept with th switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them. +Manage Toolbars: Window › Toolbars › Manage Toolbars… (or Manage Toolbars… in the drawer's ☰ menu, +`window.toolbar.manage`) lists these toolbars. A new name typed for the selected toolbar renames it at Enter, when +the text box loses focus, or at OK (`window.toolbar.rename {name, to}`; the name must not be empty, longer than 200 +characters or another toolbar's, whatever the case). The + button copies the selected toolbar with its tools as +"`name` Copy", shown a little below it and below earlier copies (`window.toolbar.duplicate {name}`), and with none +selected it makes a blank toolbar called "Toolbar N" (`window.toolbar.duplicate {}`). The trash can deletes the +selected toolbar and the groups torn off it (`window.toolbar.delete {name}`). Renaming or deleting a toolbar closes +the drawer and a group's flyout open on it; a new name that changes only the case leaves them open. Each change is +kept with the current workspace. + The Contextual Task Bar: its handle (the grip at its left end) drags it anywhere on the canvas, which always holds it whole, also when the window shrinks. Unpinned, the bar keeps that offset as it follows the selection, until another document becomes active. Its More Options (…) menu has Hide Bar (`window.taskBar`); Pin Bar Position diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba1d1738e4..5b5101a5f2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -58,8 +58,13 @@ private editing data. VectorCraft: When the PDF part of either can't be read (a stream the import refuses), the file opens from its editing data alone, with the PDF part's error as its first warning, and without the bleed and File Info the PDF part would give (part of #4366). -- **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes - Illustrator's private editing data, which is undocumented. +- **Writes** a PDF-compatible `.ai` (Save As › `ai`). With the option `editingData` (on by default, Include + Editing Data for Other Apps) every page's `PieceInfo` also carries an editing copy of the art in + `AIPrivateData` streams, in the form VectorCraft's `.ai` reader reads: layers and sublayers, groups, paths with + their fills and strokes (a compound path as one path of all its subpaths), process, spot and global colors, swatches and color groups, guides, + hidden and locked objects, object names and every artboard with its name. Type, gradients, patterns, images, + symbols, clipping groups and live objects are written as plain filled and stroked paths, each with a warning; + transparency and effects other than geometry effects are left out. - **Writes** a legacy `.ai` of version 7 (Save As › `ai` › Version: Legacy (version 7), `aiVersion: "7"`): the PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0 ([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its @@ -113,7 +118,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke or Convert to Shape comes in as itself with them and their saved settings; one with more, other effects, an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format diff --git a/docs/gaps.md b/docs/gaps.md index 016703c688..bd4f5488f3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -88,7 +88,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Distance Guides ([public page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html)): with Smart Guides on and something selected, the Selection tool with Alt/Option held over another object shows the horizontal and vertical distances between them, and over an empty part of an artboard the selection's distances from its four edges, labelled in the General unit; Preferences › Smart Guides › Invoke Distance Guides turns them off. Hover only, as documented: not while moving or nudging. No entry closed; estimates unchanged. -- **Done (part of #4028):** Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker, as the [public page](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/assign-new-colors-to-selected-artwork.html) describes ("double-click it and select a color in the Color Picker"). OK sets the row's new color in the model it was picked in, so a CMYK mix is no longer a hex value converted back; Cancel leaves the row. A Lab new color is kept, but can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke. **Missing:** Lab entry in the picker, swatch libraries in the picker and the row's right-click menu (Pick from Swatches, Limit to Library). +- **Done (part of #4028):** Recolor Artwork › Assign: double-clicking a row's new color opens the Color Picker, as the [public page](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/assign-new-colors-to-selected-artwork.html) describes. OK sets the row's new color in the model it was picked in, so a CMYK mix is no longer a hex value converted back; Cancel leaves the row. A Lab new color is kept, but can't be typed yet: the picker's Lab fields work in RGB, as for fill and stroke. **Missing:** Lab entry in the picker, swatch libraries in the picker and the row's right-click menu (Pick from Swatches, Limit to Library). - **Done (#3416):** Recolor Artwork Edit chips scroll to the last color while the heading, color wheel and actions stay visible. Screen resize and UI scaling, Preview, Confirm, Cancel, Undo and #1733 wheel drags are covered in `tests/recolor.rs`. No gap entry closed; estimates unchanged. @@ -143,7 +143,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done (#1251):** Eyedropper source reads are independent of locks and isolation, including Shift/Shift+Alt and gradient stops. Source and selection preservation, unchanged Alt reverse/selection restrictions, hidden art and Undo/Redo have regression coverage. This addresses the reported behavior, with no new full-parity measurement. - **Done (#1253):** adding paint to an unpainted zero-width stroke restores the existing 1 pt default, including new-art defaults and Swap Fill/Stroke. The real Eyedropper inheritance sequence, targeted appearance items, preserved widths and Undo/Redo have regression coverage. - **Done:** a busy cursor while a file opens off the UI thread or a labelled background save or export runs (`Cursor::Busy` in `crates/tools/src/cursors.toml`). Heavy synchronous effect previews remain open. -- **Done:** Average and Join in one step, with a corner or smooth choice for the joined anchor: Object › Path › Average & Join… (`path.averageJoin {to: "corner"|"smooth"}`, Cmd+Alt+Shift+J, Ctrl+Alt+Shift+J on Windows) moves the two direct-selected open path ends, or the nearest ends of two selected open paths (both ends of one, closing it), to their midpoint and joins them there in one undo step. The public [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists the shortcut as "Create corner or smooth join" ("Select the paths" on Windows, "Select the anchor point" on macOS). The dialog (Points: Corner / Smooth) opens on Corner and then on the choice confirmed last in the session: VectorCraft's reading, as the page doesn't say. Inside the estimate below. +- **Done:** Average and Join in one step, with a corner or smooth choice for the joined anchor: Object › Path › Average & Join… (`path.averageJoin {to: "corner"|"smooth"}`, Cmd+Alt+Shift+J, Ctrl+Alt+Shift+J on Windows) moves the two direct-selected open path ends, or the nearest ends of two selected open paths (both ends of one, closing it), to their midpoint and joins them there in one undo step. The public [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists the shortcut as "Create corner or smooth join", with the paths selected on Windows and the anchor point selected on macOS. The dialog (Points: Corner / Smooth) opens on Corner and then on the choice confirmed last in the session: VectorCraft's reading, as the page doesn't say. Inside the estimate below. - **Implemented (#1760):** Effect › Warp has a shared style selector with original previews, orientation buttons and percent sliders. Existing effects retain their stack slot and target. Focused checks passed and original captures were reviewed; optional Properties integration and remembered defaults remain outside this change. - **Done (#2120):** Smart Guides line extension: dragging the end of a straight segment with Direct Selection or Curvature, the point snaps to the segment's own direction at any angle and the guide runs along it from the other end. Inside the estimate below. - **Done (#1692):** Tab in the Color Picker moves through one color model's fields before the next. Completed upstream before the layout change (#2008). @@ -601,14 +601,15 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged. - **Partial:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they - were made in; Manage Toolbars (rename, copy, delete) is missing. Inside the estimate below. + were made in, and Window › Toolbars › Manage Toolbars renames, copies and deletes them; docking them is missing. + Inside the estimate below. - **Done (#1753):** a saved workspace keeps the width of the dock column. - **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window. - **Done:** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): a document keeps up to 25 named views (zoom, centre and rotation), saved with the file; every one is listed by name at the bottom of the View menu (`view.goto1`–`view.goto25`), and choosing one changes the current window to it. Edit Views… lists them to rename or delete, applied together on OK as one undo step (`view.saved.edit {edits}`); a file's views are checked when it opens. Estimate unchanged. - **Done:** Window › Arrange › Tile (`window.arrange.tile`) shows every open document in its own pane, edge to edge in the document area, and Consolidate All Windows (`window.arrange.consolidateAllWindows`) goes back to one document under its tab, on the desktop and the web. Estimate unchanged. - **Done:** Window › Arrange › Float in Window (`window.arrange.floatInWindow`), Float All in Windows (`window.arrange.floatAllInWindows`) and Cascade (`window.arrange.cascade`) move documents out of the tab strip into floating windows of their own, OS windows on the desktop and windows inside the app's window on the web and on Wayland; Cascade stacks them from the upper left to the lower right. Consolidate All Windows brings them back under the tabs. Estimate unchanged. - **Done:** a document tab dragged off the tab strip floats its document in a window under the pointer, and a document window dragged by its title bar (an OS window by the tab at its top) onto the tab strip goes back under the tabs. Estimate unchanged. -- **Missing:** several windows of one document (Window › New Window). +- **Done (#4116):** Window › New Window (`window.newWindow`) opens another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab titled with the view's number ("Name:2"); Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document. Closing a view leaves the document open until its last view closes. Estimate unchanged. - **Estimate:** 15–25 h. ## G6 Automation and scripting diff --git a/docs/mcp.md b/docs/mcp.md index 4911590619..72bf8f8885 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -759,7 +759,9 @@ strokes, effects or a brush comes in as its drawn look (a group named after it). document, whole and editable: point type, area type (a story in several frames as threaded type) and type on a path, with its fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), opacity, blend mode, alignment, indents and paragraph spacing. Where the text document can't say, type that shows is the page's, in its text object's -place. A text object on a layer that shows and doesn't print keeps the type the text document makes for it, on that +place. Where a layer's or group's drawn look draws a copy of art with type, the import puts the page's type that +lies over that copy's other art into the copy's group, unless the file is past the import's text-object limits. +A text object on a layer that shows and doesn't print keeps the type the text document makes for it, on that layer, even where the page doesn't draw it. A file whose editing data can't be decompressed or has symbols, pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain @@ -1445,6 +1447,12 @@ several runs, one run has to match both: type with the fill in one run and the s and stroke of other objects are not compared. Without type among the selected objects it returns the error "select a text object", and it records no undo step. +The font and text color commands (`select.same.fontFamily`, `fontFamilyStyle`, `fontFamilyStyleSize`, `fontSize`, +`textFillColor`, `textStrokeColor` and `textFillAndStrokeColor`) take the first character of the first type among the +selected objects and the objects inside them, in selection order, as the one to match. Type that is hidden or locked, +inside a hidden or locked group or layer, on a template layer, or outside the isolated group is passed over. When the +selection holds no other type, they return the error "select a text object". + ## Shaper Groups `shaper.releaseSource {source, group?}` takes the original shape `source` out of its Shaper Group: the shape becomes a @@ -1685,11 +1693,11 @@ already walks Image Trace. Short agent entry points: `dataset.delete`, `dataset.capture`, `dataset.update`, `dataset.select {name}`, `dataset.next` / `dataset.prev` (each `dataset.select` / next / prev is one undo step over every bound object), `dataset.import {path|data|dataBase64|csv}` / `dataset.export {path?}` (CSV: one column per variable, and no column of data set names). `dataset.import` also - reads tab-separated files. Every header cell is a variable, whatever its case (`name` and `Name` included), as the + reads tab-separated files. Every header cell is a variable, whatever its case (`name` and `Name` included). The + first row holds the field names, as the [set-up page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/set-up-data-source-files.html) - describes the first row: "Specify your data field name in the first row of your spreadsheet." The rows replace every - data set of the document and are named `Data Set 1`, `Data Set 2` and so on, in file order; the document's variables - and their bindings stay, and the reply's `deletedDataSets` lists the data sets that were replaced. An import error + describes. The rows replace every data set of the document and are named `Data Set 1`, `Data Set 2` and so on, in file order; + the document's variables and their bindings stay, and the reply's `deletedDataSets` lists the data sets that were replaced. An import error about a row's values gives the file line the row starts on. A header cell that names no variable defines one, unbound, in the same undo step as the rows. `#Shown` defines a visibility variable `Shown`, `@Photo` defines an image variable `Photo`, and a cell without a prefix defines a text variable. `dataset.export` writes a visibility variable's header as `#Shown` and an image @@ -1768,6 +1776,10 @@ already walks Image Trace. Short agent entry points: cannot be used. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added. The panel's Create New Action button and menu's New Action… open the `actionNew` dialog, whose Record runs this. +- Actions: `ui.action.record {set?}` starts recording a new action in the chosen set, or the current set when `set` + is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next available + `Action N` name in that set. The Actions panel's Begin Recording button runs this command. An invalid set or an + existing recording is an error and leaves the sets and recording unchanged. - Actions: `ui.action.options {set, action, name?, key?, cmd?, shift?, color?}` changes the action at `action` in set `set` and keeps its steps: `name` renames it, `key` with `cmd` and `shift` sets its function key, and `color` its Button Mode color, as `ui.action.new` takes them; a param left out keeps what the action has, and @@ -2568,7 +2580,9 @@ and `layerRows`, the rows highlighted in the panel. while a symbol is edited only the editing layer or art on it) / `object.exitIsolation`. - **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer {sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview, - dimImages, dimPercent; OK is one undo step; Cmd/Ctrl+Alt+L runs `ui.newLayer` for a new layer), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: + dimImages, dimPercent; OK is one undo step; `ui.dialog.set` of `template` also sets `locked` and `dimImages`, as the + checkbox does; for several `ids` the dialog shows the first row's settings, and OK sends only the fields set with a + control or `ui.dialog.set`; Cmd/Ctrl+Alt+L runs `ui.newLayer` for a new layer), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: layersOnly, locateSelected, rowSize small|medium|large|other, otherSize, thumbLayers, thumbGroups, thumbObjects), and `ui.layersExpand {ids?, open?}` opens or closes rows as their triangles do (Alt-click: everything inside). With `locateSelected` on (Locate Selected Objects, off by default), when the last selected object changes on the canvas @@ -3692,6 +3706,17 @@ it returns `{tiled, floating}`. An `index` that names no open document fails wit is not saved. `ui.inspect` reports `tiled`, `panes` (each pane's `document` index, `rect` and whether it is `active`) and `floating`; canvas coordinates map to the current document's pane or window (`canvasRect`), in that window's coordinates while it floats. +`window.newWindow` (Window › New Window) opens another view of the current document, with its own zoom, center, +rotation, artboard and Outline or Preview, makes it the current window and returns `{index, view}`, the view's number. +Each view has a tab of its own; while a document has more than one view, its tabs and windows show the view's number +after the name ("Name:2"). It opens in a floating window when the view it came from is in one. With no document open +it fails with "no document open". The Arrange commands treat each view as a window: `floatInWindow` and +`consolidateAllWindows` take `view` beside `index` (the document's current view without it), and Tile and Cascade +count views. `file.close` without an `index` closes the current view, and the document's last view closes the document; +with an `index` it closes the document and all its views. `window.nextDocument` and `window.previousDocument` step +through the views in tab order and add `view` to their reply for a document with more than one view. `ui.inspect` +reports `windows` (every window as `[document index, view number]`, in tab order), `currentWindow` and each pane's +`view`. Views are not saved. ```json {"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}} diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index d21ae4145e..1ddf3c03e1 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,7 +1,7 @@ # PDF backend PDF writing uses `craft-pdf` from `storytold/craft-libs`, pinned to -`f8f8829cc7be65d466d687dae381d1ca60e31242`. Its `write` feature supplies Krilla configuration, +`9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb`. Its `write` feature supplies Krilla configuration, metadata construction, fallible font preparation and serialization, and native unwind guards. The dependency disables default features and enables `write` and `interpret`. The production writer retains Krilla’s raster-image support without its optional text shaper. @@ -16,7 +16,7 @@ Desktop printer submission remains in `craft-print`. The shared writer vendors Krilla 0.8.2 with an unwind-safe cleanup and poisoning patch. The root `[patch.crates-io]` selects that copy from craft-libs revision -`f8f8829cc7be65d466d687dae381d1ca60e31242`, which includes craft-libs#417 (CMYK, gray and spot +`9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb`, which includes craft-libs#417 (CMYK, gray and spot colors keep their exact inks): Cargo does not inherit a dependency workspace's patches. A failed page or surface cleanup poisons the backend document, so serialization cannot return an incomplete PDF after unwinding. The shared crate's compile-time backend marker and `cargo xtask pdf-deps` (included in CI) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b544fdf895..412dbba039 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,9 +1,11 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Type core options status and Kashidas feature revision corrected; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; an imported document takes the title its file carries; previously: new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence +Ligature caret stops use a font's GDEF contour-point positions in horizontal and vertical text. They use unhinted outlines at the default variation location; invalid point indices use the existing evenly divided fallback. Scores and estimates are unchanged. + Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. **Feature evidence last updated:** 2026-11-03. @@ -21,6 +23,9 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Correct the Type core missing list and remove duplicate target and Type core entries: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | +| 2026-11-03 | minor | Correct the Type core missing list: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. | +| 2026-11-03 | minor | Ligature caret stops use GDEF contour-point positions in horizontal and vertical type at the font's default variation location; invalid point indices keep the evenly divided fallback. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | @@ -137,7 +142,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests) | +| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | @@ -145,9 +150,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | -| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated | @@ -408,12 +411,11 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | -| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); the Every-line Composer (justified and ragged text, per object) is tuned by eye and mixed type sizes in a paragraph still break single-line (Hyphenation… and Justification… options are done, #2310, #2755), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | -| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Fractional Widths and System Layout are done | 14–19 | +| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths and System Layout are done | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | more built-in symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | @@ -520,7 +522,7 @@ since 2026-10-01. - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). -- **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Set options (except for Actions, Brushes, Swatches, and Symbols panels)". +- **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. - **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing` and `command_drag_stops_at_the_largest_position_and_keeps_the_spacing`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. @@ -536,10 +538,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). - **Saved views (G7):** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): up to 25 views per document, saved with it, renamed or deleted in Edit Views, chosen from the bottom of the View menu). Each view keeps the zoom, centre and rotation; the View menu lists all 25 (`view.goto1`–`view.goto25`), and Edit Views… lists them to rename or delete, applied together as one undo step. A file's views are checked when it opens (at most 25, unique names up to 255 characters, a finite centre and a positive zoom). Tests: `saved_views_round_trip_and_from_a_file_are_tidied` (`crates/format/tests/prop_format.rs`), `crates/engine/src/cmd/views.rs`, `crates/ui-egui/src/dialogs/edit_views.rs` and `every_saved_view_is_listed_at_the_bottom_of_the_view_menu` (`crates/ui-egui/src/menus.rs`). - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. -- **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): "Tile displays windows edge to edge"), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. -- **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html): "drag the window's tab out of the group"). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): "Cascade displays windows stacked and descending from the upper-left to the lower-right of the screen"). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. -- **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html): "To undock (float or untab) a Document window from a group of windows, drag the window’s tab out of the group. To dock a Document window to a separate group of Document windows, drag the window into the group."). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. -- **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html): "When you create or open documents, they are arranged in tabs in a single document window"). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. +- **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. +- **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. +- **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. +- **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. +- **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). @@ -566,13 +569,13 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: when no object is bound to a variable that the data set has a value for, for example after Import Data Sets… defines new variables without binding them, choosing the data set in the Variables panel or stepping to it with the panel's arrows shows a status message, and `dataset.select`, `dataset.next` and `dataset.prev` add the message to the reply as `warning`. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - - Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html): "Select Save Actions from the Actions panel menu", "You can save only the entire contents of a set", "Select Load Actions from the Actions panel menu"; `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number. + - Actions panel menu: Save Actions… writes the current action set with all its actions to a file, and Load Actions… adds the set from such a file to the panel ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html); `ui.actionSet.save`, `ui.actionSet.load`). The file is VectorCraft's own action set file (`.vcactions`); a loaded set named like one in the panel gets the next free number. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Removes the selected action, command, or action set from the panel"; `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Makes a copy of the selected action or set."; `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. + - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. + - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. - Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used. - Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html). - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). @@ -603,7 +606,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A click on another object moves the contextual task bar under it without hiding it for the press; it gives way only to a drag (#4681). - With one image selected, the contextual task bar offers Image Trace, which traces the image at once with the Default preset (the Image Trace panel is left as it was), and Crop Image; with an Image Trace object it offers Expand, which keeps only the traced paths. This follows Illustrator's bar: for a selected image it includes Image Trace, Retype and Crop Image, and traced artwork can be expanded from it ([Contextual Task Bar announcement](https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580)). Offset Path and Simplify, which don't apply to an image, aren't offered for one. Not done: Retype, which the app doesn't have. - With the Artboard tool, the contextual task bar sits under the selected artboards and offers Resize (Artboard Options for the active artboard), Duplicate (each selected artboard, in one undo step, the copies then selected), Rearrange All (with two or more artboards) and Export (Export for Screens with the selected artboards checked). This follows Illustrator's bar: while the Artboard tool is in use, it has options for resizing, duplicating and exporting artboards (the [Contextual Task Bar announcement](https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580); Rearrange and Export on the bar: [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/)). - - The contextual task bar offers more next steps by selection (): an open path Join and Edit Path (the Direct Selection tool), a closed path Edit Path, a compound path Edit Path and Release Compound Path, a clipping group Edit Clipping Path and Release Clipping Mask instead of Ungroup and Isolate, an image selected with vector art Make Clipping Mask instead of Unite, and point or area type Convert To Area Type or Convert To Point Type beside Create Outlines. Evidence: `crates/ui-egui/src/tests/taskbar.rs`. + - The contextual task bar offers more next steps by selection (): an open path Join and Edit Path (the Direct Selection tool), a closed path Edit Path, a compound path Edit Path and Release Compound Path, a clipping group Edit Clipping Path and Release Clipping Mask instead of Ungroup and Isolate, an image selected with vector art Make Clipping Mask instead of Unite, point or area type Convert To Area Type or Convert To Point Type beside Create Outlines, and type on a path, for which the overview names an item to add an effect and rearrange the text, Type on a Path Options (`type.pathOptions`; its options: ) beside Create Outlines. Evidence: `crates/ui-egui/src/tests/taskbar.rs`. - With a live rectangle, rounded rectangle, ellipse or polygon selected with the Selection tool, the contextual task bar shows a Shape Properties button in place of Lock. It opens a popup with the shape's rows from the Transform panel (corner radius, pie angles, or sides, angle, corner and radius), which edit it through `object.setLiveShape`. The overview describes this: when a shape drawn with a shape tool (Rectangle, Star) is selected, the bar offers to change its properties where Lock Object would be ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Object › Lock and ⌘2 still lock the shape. Not done: stars, which the app draws as plain paths. Evidence: `a_basic_shape_shows_its_properties_in_place_of_lock`, `the_shape_properties_popup_edits_the_shape` and `other_selections_keep_lock` in `crates/ui-egui/src/tests/taskbar.rs`. - The contextual task bar has a Stroke chip beside the Fill chip, drawn with the paint the Stroke proxy shows (a "?" when the selected objects' strokes differ, as on the Fill chip); a click brings the Stroke proxy forward and shows the Swatches panel wherever it is docked, floating or closed, as a click on the Fill chip does for the Fill proxy. The reference app's bar has Fill and Stroke controls for changing an object's colors ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Evidence: `the_task_bar_shows_a_stroke_chip_beside_fill` and `the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere` in `crates/ui-egui/src/canvas/tests.rs`. - With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, Corner and Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons. As in Illustrator, whose bar has items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Not done: Simplify, which the bar offers only while no anchors are selected, and the bar's Smooth item that adjusts a path's curvature, a command the app doesn't have (Object › Path › Smooth). @@ -614,7 +617,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). Manage Toolbars is not done. + - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). + - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). @@ -635,7 +639,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them. - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in library, Basic Shapes, is original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. - - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html): "When you scale the symbol, the parts of the symbol that don't lie within the guides are not scaled."): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), "Use 9-slice scaling": "guides are placed at 25% (or 1/4)"); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). + - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. @@ -685,7 +689,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new symbol. OK makes the symbol and puts an instance of it in place of the art, and Cancel makes none. With nothing selected, F8 opens no dialog, and the status bar says that nothing is selected. Shift+F8 and Cmd/Ctrl+F8 still show the Transform and Info panels. - Cmd/Ctrl+Shift+X (`type.resetHorizontalScale`) sets the horizontal scale of the selected type, or of the text the Type tool has selected, to 100% as the Character panel shows it (the object's own stretch included), in one undo step; at a caret it leaves the text as it is. - Cmd/Ctrl+Shift+U (`type.underline`) underlines the selected type, or the text the Type tool has selected, and removes the underline again, as the Character panel's Underline button does. Cmd/Ctrl+Alt+Shift+C (`type.lineComposer`) switches the paragraphs between the Single-line and the Every-line Composer, and Cmd/Ctrl+Alt+Shift+H (`type.hyphenate`) turns their hyphenation on and off, as the Hyphenate checkbox does. These are the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for underlining and for toggling auto-hyphenation, and on Windows for toggling the line composer. The page's macOS key for the line composer ends after Command, and VectorCraft uses C there as well. - - Cmd/Ctrl+Alt+N (`file.newWithoutDialog`) makes a document without opening the New Document dialog, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new document with no New Document dialog box. The page does not say which settings the document gets. VectorCraft gives it the settings the dialog opens on (the first Recent entry, or the Print category's Letter while Recent is empty) and the next untitled name. + - Cmd/Ctrl+Alt+N (`file.newWithoutDialog`) makes a document without opening the New Document dialog, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page assigns to creating a document directly. The page does not say which settings the document gets. VectorCraft gives it the settings the dialog opens on (the first Recent entry, or the Print category's Letter while Recent is empty) and the next untitled name. - Cmd/Ctrl+F6 (`window.nextDocument`) and Cmd/Ctrl+Shift+F6 (`window.previousDocument`) make the next and the previous open document the current one, in tab order and as a click on its tab does, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for navigating to the next and the previous document. After the last document comes the first, and before the first document comes the last. From the Home screen the keys go to the first and the last document. Keyboard Shortcuts lists both under Window. - Cmd/Ctrl+Alt+Shift+U (`document.switchUnits`) switches the document to the next unit of measurement, in one undo step, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for switching units of measurement. The page does not give an order. VectorCraft steps through the units in the order a right-click on a ruler lists them (Points, Picas, Inches, Millimeters, Centimeters, Pixels, Feet & Inches, Meters, Yards, Feet) and goes from Feet back to Points. - Shortcut sets other apps save as text (`.kys`) open in Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts › Import… in the desktop app, or from a drop on the window. It matches each entry to a tool, command or panel by name and decodes the file's key and modifier codes where VectorCraft's default shortcuts agree on them; the user answers the rest, and Apply fills the Keyboard Shortcuts dialog's working set. The answers are kept with the preferences for the next import (`shortcutSet.read`, `importMatches.*`). @@ -768,14 +772,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, and the opacity set on a mark's top fill or stroke row, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). While a Type tool edits type, a click that lands on no type ends the editing without making new type, and that tool stays active; the next click makes new type (#2871, #2854). - Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. + Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. Contour-point carets use the font's unhinted outline at its default variation location; pixel-size device adjustments are not applied. Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged. @@ -792,7 +796,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In the Swatches panel, Alt-dragging a swatch onto another swatch of its kind (not a tint swatch) replaces that swatch's color, gradient or pattern, and the swatch keeps its name and place. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too; Move opens on the offset of the last drag or Move and with the checkboxes it was last confirmed with, so Enter, Enter repeats a move or a copy at the same spacing, as [an Adobe Community thread](https://community.adobe.com/questions-652/move-tool-771221) describes), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, Keep selected, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel. Double-clicking the Line Segment, Arc, Rectangular Grid or Polar Grid tool opens its Tool Options, as step 3 of the [Rectangular Grid tool](https://helpx.adobe.com/illustrator/using/tool-techniques/rectangular-grid-tool.html) and [Arc tool](https://helpx.adobe.com/illustrator/using/tool-techniques/arc-tool.html) pages and the [Tools](https://helpx.adobe.com/illustrator/using/tools.html) page describe (#2691): the dialog a click with the tool opens, without a place to draw. Line Segment Tool Options has Length, Angle and Fill Line (a new line takes the current fill, `shape.line` `fill`); Arc Segment Tool Options its lengths, Reference Point, Type, Base Along, Slope and Fill Arc; the grids' Tool Options the Reference Point, Default Size, dividers, skews and switches. OK draws nothing: the next drag draws with the options and a click's dialog opens at them, the Length, Angle and Default Size included; Cancel changes nothing. Tests: `toolbar::tests::double_clicking_the_line_arc_and_grid_tools_opens_their_tool_options`, `line_segment_options_and_fill_line`, `grid_default_size_is_an_option_the_drag_leaves_out`, `fill_line_gives_a_new_line_the_current_fill`. - A closed arc from Arc Segment Tool Options or `shape.arc` with `lengthX` and `lengthY` closes through the corner of its box across from the one a convex arc bulges toward, with either Base Along, so a closed arc at Slope 100 is a quarter-ellipse sector and the Y Axis arc is the X Axis arc mirrored top to bottom. The [Draw arcs](https://web.archive.org/web/20251025042220/https://helpx.adobe.com/illustrator/using/drawing-simple-lines-shapes.html) section's Type option makes "an open path or a closed path"; the corner it closes through is VectorCraft's own rule. Tests: `shapes::tests::a_y_base_arc_is_the_x_base_arc_mirrored_top_to_bottom`, `a_closed_convex_arc_is_a_quarter_ellipse_sector_with_either_base`, `tests::draw2::shape_arc_closed_along_y_closes_through_the_top_left_corner`. + A closed arc from Arc Segment Tool Options or `shape.arc` with `lengthX` and `lengthY` closes through the corner of its box across from the one a convex arc bulges toward, with either Base Along, so a closed arc at Slope 100 is a quarter-ellipse sector and the Y Axis arc is the X Axis arc mirrored top to bottom. The [Draw arcs](https://web.archive.org/web/20251025042220/https://helpx.adobe.com/illustrator/using/drawing-simple-lines-shapes.html) section's Type option chooses whether the arc is open or closed; the corner it closes through is VectorCraft's own rule. Tests: `shapes::tests::a_y_base_arc_is_the_x_base_arc_mirrored_top_to_bottom`, `a_closed_convex_arc_is_a_quarter_ellipse_sector_with_either_base`, `tests::draw2::shape_arc_closed_along_y_closes_through_the_top_left_corner`. Live curve fitting in the Pencil Tool Options is on by default; while it is off, the path shown while a stroke is drawn joins the stroke's points with straight segments, and the release fits the curve, as [Pencil Tool Options](https://web.archive.org/web/20261002104400/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/pencil-tool-options.html) describes (`path.freehand` `fit`). In the Pencil and Paintbrush Tool Options, Edit selected paths within 0 px turns editing off, so a stroke that starts exactly on an end of a selected open path draws a new path; Close paths when ends are within 0 px turns closing off, so a stroke that ends exactly where it started stays open. Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). @@ -820,6 +824,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Alt-clicking a layer's or sublayer's name makes it the current layer, as a click does, and selects its visible, unlocked art, as its selection square does (`layer.selectAll`, then `layer.setCurrent`); on a hidden or locked layer it clears the selection. The reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists Alt-click on the layer name for selecting all objects on the layer. Object › Arrange › Send to Current Layer returns the error "the target layer is locked or hidden" and leaves the selected objects where they are when the current layer is hidden or locked, or is inside a hidden or locked layer. Selected objects are deselected, the key object included, when a row drag moves them into a hidden layer or group, when `node.move` moves them into a hidden or locked layer or group, when Merge Selected moves them into a hidden or locked layer, when Collect in New Layer moves them into a new layer inside a hidden or locked layer, when Flatten Artwork moves them into a locked layer, and when applying a data set hides them. + Layer Options for several highlighted rows shows the first row's settings, and OK changes only the settings set in the dialog: each row keeps its own color, Template, Lock, Show, Print, Preview and Dim Images otherwise (`crates/ui-egui/src/dialogs/layer_options.rs` tests). Panel Options › Locate Selected Objects (beyond Illustrator, off by default): when the last selected object changes on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as Locate Object does, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off. - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only. @@ -896,7 +901,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). - **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. -- **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) ("Kerning is the process of adding or subtracting space between specific pairs of characters."). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. +- **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. - **Manual kerning on inline graphics:** a manual kerning value on an inline graphic's character spaces it from the next character in reading order by the same distance as on a letter of its run's size, in left-to-right, right-to-left and vertical type; Auto and Optical kerning still apply no font pairs to it. This is VectorCraft's own rule. Test: `manual_kerning_on_an_inline_graphic_spaces_it_like_a_letter`. - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. @@ -918,6 +923,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type › Create Outlines, Object › Expand and Object › Expand Appearance on a frame of threaded text take it out of the thread with the text it shows, which the outlines draw, and the other frames keep the text they hold. When the last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps the frame as type in a group with its shadow, outer glow or Crop Marks, the type stays in the thread. The public help on threads ([Create text threads between objects](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html), [Remove or break text threads between objects](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/remove-or-break-text-threads-between-objects.html)) does not describe outlining a threaded frame; this handling is VectorCraft's own behavior. Select › Same › Link Block Series selects every frame of the threads of the selected type; type in no thread selects itself (`select.same.linkBlockSeries`). The menu entry is listed in [Select objects by characteristics](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects-by-characteristics.html); selecting several threads at once and type in no thread selecting itself are VectorCraft's own behavior. Select › Same › Text Fill & Stroke Color selects the type whose characters match both the fill and the stroke of the selected type (`select.same.textFillAndStrokeColor`). The menu entry is listed in [Select objects by characteristics](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects-by-characteristics.html); requiring one run of characters to match both is VectorCraft's own behavior. + The Select › Same font and text color commands take the type to match from the selected type that can be selected: type that is hidden or locked, on a template layer, or outside the isolated group is passed over. This is VectorCraft's own behavior. Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. @@ -1038,10 +1044,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Hue Shift set by `brush.new` or `brush.options` without a key color takes the color the art's fills and strokes use most, as Brush Options already offers it, following the Brushes page, where the key color defaults to the color that stands out most in the art (). The page does not state how prominence is measured. Counting the fills and strokes that use each solid color, with the first one found winning a tie and black for art with no solid color, is VectorCraft's own rule. A `brush.options` edit that gives only the method keeps the brush's key color, and one that gives only an art brush's start guide keeps its end guide. Evidence: `tests_brushsym::hue_shift_without_a_key_takes_the_most_used_art_color`, `tests_brushsym::art_brush_options_keep_the_scale_fields_an_edit_leaves_out`. Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests/brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy). In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). - The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page (archived copy) lists ("Delete without confirmation (except for Layers panel)"). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. + The Brushes panel's Delete Brush button and panel menu item ask "Delete the selected brushes?" with Yes and No when none of the brushes is in use. Alt-click (Option-click on macOS) on the button deletes them without asking, as the [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for panel deletion outside the Layers panel (archived copy). When a stroke uses one of the brushes, the button, Alt-click and the menu item all ask with Remove Strokes and Cancel; Remove Strokes deletes the brushes and the strokes that used them become plain strokes. Tests: `panels::brushes::tests`, `dialogs::brush_delete::tests`. Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). - Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. + Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes. With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). @@ -1076,7 +1082,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Move in Perspective (its copy, perpendicular and Snap to Grid choices), Transform in Perspective (its copy), Nudge in Perspective (its ×10 and copy), Draw in Perspective (Snap to Grid), Define Perspective Grid (its reproject choice), Show Perspective Grid, Width Point (Adjust Adjoining Width Points), Liquify (its pen pressure, Simplify and Affect choices) and Puppet Warp (its pins' rest shape) refuse a value other than true, false or null, such as "true", and leave the document and its undo history as they were. Moves and transforms in perspective and Define Perspective Grid with reproject return an error, and leave the document and its undo history as they were, when the projection they would give type or a symbol instance in perspective has a number too large to store. Object › Perspective › Edit Text returns an error, and does not enter isolation mode, when the type cannot be shown flat where it is drawn: when part of it crosses the horizon, or when that view's projection has a number too large to store. Regressions: `a_product_that_overflows_is_none` in `crates/geom/src/projective.rs`, and `moving_type_whose_projection_would_overflow_is_refused` and `edit_text_whose_flat_view_would_overflow_is_refused` in `crates/engine/src/tests/persp_text.rs`. A stored projection with numbers so large that its determinant overflows is judged on a copy divided by a power of two: an invertible one is read back when the document opens as the same map at a smaller scale (divided by the largest absolute value in its bottom row), so points that overflowed at the stored scale draw, and one that cannot be inverted, such as one whose bottom row is 0 at that scale, is not. Regression: `a_determinant_that_overflows_is_taken_at_a_smaller_scale` in `crates/geom/src/projective.rs`. - A projection gives the same answers when every one of its numbers is multiplied by the same factor. A point is on or past its horizon when its w is at most 1e-9 times the largest absolute value in the bottom row, so a point with a negative w is past it. The projection is invertible when the absolute value of its determinant is more than 1e-15 times the largest absolute value among the six products the determinant sums, so a negative determinant counts the same as a positive one. A stored projection with very small numbers draws and inverts, and one that cannot be inverted is refused at every scale. Regression: `the_checks_do_not_depend_on_the_scale_of_the_map` in `crates/geom/src/projective.rs`. + A projection gives the same answers when every one of its numbers is multiplied by the same factor. A point is on or past its horizon when its w is at most 1e-9 times the largest absolute value in the bottom row, so a point with a negative w is past it. The projection is invertible when the absolute value of its determinant, or of its adjugate's determinant, is more than 1e-15 times the largest absolute value among the six products that determinant sums, so a negative determinant counts the same as a positive one, and the inverse of a projection that inverts also inverts and is stored and read back. A stored projection with very small numbers draws and inverts, and one that cannot be inverted is refused at every scale. Moving or transforming in perspective sees the projection through the move's affine map, and that map counts as invertible on the same test of its determinant, so a move scaled by 1e-8 or 1e8 is applied and one that flattens the art to a line is refused. Regressions: `the_checks_do_not_depend_on_the_scale_of_the_map`, `the_inverse_of_an_invertible_map_is_invertible` and `conjugating_does_not_depend_on_the_scale_of_the_affine` in `crates/geom/src/projective.rs`. Create Object Mosaic (its columns and rows), Transform Each (its reference point and Random seed) and Rearrange All Artboards (its columns) refuse a fraction, a negative number or a string, such as 2.5, -1 or "4"; 4.0 counts as 4. Move, Rotate, Scale, Reflect, Shear, Transform Again, Nudge and the Transform panel's values (their copy, Scale Strokes & Effects, Scale Corners, Transform Patterns, Transform Objects and area type options), Align, Distribute and Distribute Spacing (Transform Patterns), Object Properties (visible, locked, Isolate Blending and Opacity & Mask Define Knockout Shape) and Live Shape Properties (Invert Pie, Make Sides Equal and relative rounding) refuse a value other than true, false or null, such as "false", and leave the document as it was. Object Properties (its opacity), Live Shape Properties (corner radius and polygon sides), the Transform panel's reference point and Align, Distribute and Distribute Spacing to an artboard (the artboard's number) refuse a value other than a number or null, such as "50", and leave the document as it was. @@ -1127,6 +1133,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Legacy `.ai` saves (version 7) write type with rotated characters (Character Rotation, Touch Type turns) as its outlines, with a warning that it is no longer editable text (unless `outlineText` already asks for every type object as outlines): a version 7 text object has no character rotation ([Adobe Illustrator File Format Specification 7.0](https://www.nationalarchives.gov.uk/PRONOM/fmt/423), §15), and the outlines keep each character's rotation, baseline shift and scale (`legacy_ai::tests::type_with_rotated_characters_is_outlines_that_keep_the_rotation`). A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304). In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`). + In a `.ai` or EPS opened from its editing data, where a layer's or group's drawn look draws a copy of art with type, the import puts the page's type that lies over the copy's other art into the copy's group, instead of giving it to other text objects by painting order. Type beside the copy's art, and every copy's type in a file past the import's text-object limits, still goes by painting order. Without a page, such a copy has no type, and no warning counts it as left out (`crates/eps/src/import/tests_native.rs`). A `.ai` opened from its editing data keeps Effect › Pathfinder on a group with no fills or strokes of its own as a live effect, with the group's members editable: Add, shown or hidden, in its order among the group's effects. The other operations, Pathfinder Options values other than the ones the reader accepts, and the effect on a path, on type, or on a fill or stroke open as the drawn look (`crates/eps/src/import/ai/style.rs`, `pathfinder_effect`). PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9). A `.ai` or PDF whose editing data holds a readable variable library opens with its variable definitions, data-set rows, text, visibility and linked-file values, active row and lock in the Variables panel, whether its layers are read or it comes in as its PDF part; graph values and malformed entries are skipped with a note, and objects aren't bound yet (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). @@ -1165,6 +1172,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); CNC preset and VCarve QA remain pending. + - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. - Placed documents' bitmaps on screen: when a bitmap made in the background is ready, the desktop app renders the canvas again only where the objects that show its file are, when the frame shown was rendered on the UI thread and that part is at most half the canvas. Bitmaps drawn or made since a frame began are kept for that frame past the 512 MiB budget, up to 2 GiB, so a view showing more than the budget does not make them again in every frame. A bitmap that would take them past 2 GiB is not kept, and its file draws exactly at that zoom. A bitmap that can't be made (the file can't be read and there is no preview) shows the light placeholder and is not asked for again. Each ready bitmap used to render the whole canvas again, and bitmaps past the budget and bitmaps that could not be made were asked for again in every frame (`crates/render/src/tests/placed_document.rs`). @@ -1354,7 +1362,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi `pdf::tests::dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill` and the Gradient panel's `the_method_menu_makes_the_gradient_perceptual`. Scores and hours unchanged. -PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html): "Gradient colors can be assigned as CMYK process colors, RGB colors, or a Spot color."; [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html): "a separate plate is created for each spot color"). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with RGB or Lab stops are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve` and `ink_mixes_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. +PDF export writes a Perceptual linear or radial gradient whose stops are tints of one spot color (and paper white) as a Separation shading in that ink instead of an image, so the ink keeps its plate ([swatches](https://helpx.adobe.com/illustrator/using/using-creating-swatches.html); [separations](https://helpx.adobe.com/illustrator/using/printing-color-separations.html)). Its tints follow the curve's stops, each the tint with the OKLab lightness of the color there (VectorCraft's own rule). A Perceptual gradient that mixes spot colors with each other or with CMYK or gray stops is written as a DeviceN shading of its inks: at each stop on the curve, the inks of the two stops on either side are mixed at the weight where the two colors mixed in display RGB have the OKLab lightness of the color there (VectorCraft's own rule). Dithered fills, freeform gradients and Perceptual gradients that mix a spot color with RGB or Lab stops are still images, and the export warns that their spot colors are written in process colors. Tests: `color::gradient::tests::ink_tints_follow_the_perceptual_curve` and `ink_mixes_follow_the_perceptual_curve`, `engine::tests::linked_stops::perceptual_spot_gradients_export_as_separation_shadings`, `perceptual_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings` and `spot_gradients_drawn_as_images_warn`. In these Separation and DeviceN shadings, a white stop linked to a swatch that is not a spot color (a global White swatch, for example) is paper white, 0% of the ink, as an unlinked white stop is, so a gradient of one spot ink and such a stop stays a Separation shading; a stop linked to a white spot color stays a stop of that ink (VectorCraft's own rule). Test: `engine::tests::linked_stops::white_stops_linked_to_process_swatches_are_paper_in_spot_gradients`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 26d3dd6790..843ca66c75 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Type on a Path Options has a Preview checkbox, #5360; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Type on a Path Options has a Preview checkbox, #5360; the Action Options row cites the public Actions panel page; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -30,6 +30,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | The Action Options row cites the public Actions panel page and describes its controls. Scores and estimates remain unchanged. | | 2026-11-03 | minor | Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | @@ -308,20 +309,21 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Panels dragged onto a dock group's tabs join it; onto its upper or lower half they make a new group above or below it (a top/bottom split, resizable); onto the icon column they become icons, the tabbed group's panels too; a dock tab's context menu closes it (Window menu reopens it) | done | `window.panel.dock {anchor, zone}`, `window.panel.dock {icon: true}`, `window.panel.close` | | A double-click on a dock group's tab collapses the group to its tabs (a group below it moves up into the room) and a second one expands it; a click on another of its tabs expands it showing that panel ([helpx: Workspace basics](https://helpx.adobe.com/illustrator/using/workspace-basics.html), panel groups) | done | `window.panel.collapse {panel, collapsed?}`; in dock groups and floating windows; a group beside another (a left/right split) doesn't collapse | | Panels outside the app window (another monitor) | done | floating groups in OS windows of their own on the desktop (#4135) | -| Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) | +| Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; tool flyouts are as wide as their longest translated name and shortcut need (#1616) | | Window › Toolbars › New Toolbar… (also in the All Tools drawer's menu): a name and OK make a blank floating toolbar, listed under Window › Toolbars (its item shows or hides it); its title bar moves it, its × hides it and its double arrow shows its tools in one column or two; its ••• button opens the All Tools drawer beside it to fill it; kept with the workspace it was made in | done | #1759 in part; `window.toolbar.new`, `window.toolbar.custom`, `window.toolbar.list`, the `toolbar` param of `window.toolbarColumns`, `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset` | +| Window › Toolbars › Manage Toolbars… (also in the All Tools drawer's menu): the dialog lists the toolbars made with New Toolbar; a new name typed for the selected one renames it, + copies it (none selected: a blank toolbar) and the trash can deletes it; the changes stay in the workspace | done | #1759 in part; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete` | | Quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the editable controls | done | Per the public documentation, choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or in Properties' Image Trace section (). The controls show Threshold, Paths, Corners and Noise with Advanced open; `panels/image_trace.rs` headless tests (`a_quick_trace_leaves_the_panel_as_it_was`, `sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source` for the button's route). No algorithm parity claim. | | Image Trace panel: holding the eye button next to View down shows the selected Image Trace objects' source image on the canvas | done | Per the public documentation, the control next to View is pressed and held to compare the trace result with the source image (). On release the objects show their View again; the document, its View and the undo history are unchanged. `RenderOptions::trace_source`; `panels/image_trace.rs` headless test `holding_the_eye_button_shows_the_source_image_until_released`, render test `trace_source_draws_the_source_image_of_the_listed_objects`. The Control bar and Properties have no such button | | Live trace result bounds and selection highlights exclude the retained bitmap; source-image views retain it | done | All TraceView modes and rotated result bounds regression-covered; ordinary hidden group behavior retained | | Image Trace panel: the Preview checkbox before Trace chooses whether a setting change traces the selected Image Trace object again at once | done | Per the public documentation, Preview shows the result of the current settings and Trace traces with them (). Preview is on by default and kept with the UI preferences; Reset to Default leaves it. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected, and come back when it is selected again. `imageTrace.preview {on?}`, `ui.set {imageTracePreview}`; `panels/image_trace.rs` headless tests (`with_preview_off_a_slider_change_waits_for_trace`, `turning_preview_on_traces_the_changes_that_waited`, `preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again`, `settings_that_wait_stay_with_their_object`, `settings_that_wait_come_back_when_their_object_is_selected_again`, `settings_that_wait_are_dropped_when_their_object_changed_since`, `preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it`) | | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | | Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; the reference app's `.aia` files and Reset, Clear and Replace Actions remain | -| Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Opens a dialog to rename or assign function keys/colors to the action") | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). The color shows nowhere until Button Mode exists | +| Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). The color shows nowhere until Button Mode exists | | Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | | Actions panel: Create New Set (bottom bar, ≡ › New Set…) names a new set and Set Options… renames the current one; a click on a set's name makes it current and a recording goes into the current set ([helpx: Automation with actions](https://helpx.adobe.com/illustrator/using/automation-actions.html)); the current set is kept with the UI preferences, and with no set current a recording goes into the first set, which becomes current | partial | #3712; dragging actions between sets remains | | Actions panel: Create New Action (bottom bar, ≡ › New Action…) asks for the name, set, function key and color, and Record adds the action and records into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)) | partial | `ui.action.new`, `ui.action.play`; `panels/actions.rs` tests. A function key plays its action, even when a command has the same shortcut, and the command keeps it. Button Mode, which shows the color, remains | -| Actions panel: Delete Selection (bottom bar) deletes the selected set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Removes the selected action, command, or action set from the panel") | partial | `ui.actionSet.delete`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: deleting a set asks first (a selected action is still deleted at once), Shift-click on a set's name selects a range of sets, ≡ › Delete does the same as the button, and Default Actions and the last set can be deleted. Undo does not restore a deleted set, and recorded commands cannot be selected or deleted | -| Actions panel: ≡ › Duplicate copies the selected action or set ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): "Makes a copy of the selected action or set.") | partial | `ui.actionSet.duplicate`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected, and a copied set holds copies of all its actions. Undo does not remove the copy, and recorded commands cannot be duplicated | +| Actions panel: Delete Selection (bottom bar) deletes the selected set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.delete`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: deleting a set asks first (a selected action is still deleted at once), Shift-click on a set's name selects a range of sets, ≡ › Delete does the same as the button, and Default Actions and the last set can be deleted. Undo does not restore a deleted set, and recorded commands cannot be selected or deleted | +| Actions panel: ≡ › Duplicate copies the selected action or set ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.actionSet.duplicate`; `panels/actions.rs` tests. VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected, and a copied set holds copies of all its actions. Undo does not remove the copy, and recorded commands cannot be duplicated | | Properties panel per context (every selection type's Quick Actions) | partial | one clip group: Edit Clipping Path and Edit Contents, at the top of the panel rather than in Quick Actions (G1); the overview's additional controls ([Properties panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/properties-panel-overview.html)): Blend Options (one blend selected, with any tool, #4817) and Merge Live Paint (a Live Paint group with more) in Quick Actions, the Width Profile list (Width tool or a variable-width stroke) and an applied effect's Delete icon in Appearance, Corner and Corner Radius for corners selected with Direct Selection; Variable Font was already there | | Select › Start Global Edit / Stop Global Edit; Properties › Quick Actions with the Global Edit Options popover (Match: Appearance, Size; On Artboards: All Artboards or a range, Include Objects On Canvas; Result(s)); Escape stops it; the matched objects are highlighted in their layer colour while it runs; a Shift-click on a highlighted match leaves it out | partial | #3521, `select.globalEdit` / `select.globalEditOptions` / `select.globalEditExclude`, `crates/engine/src/tests/globaledit.rs`; paths, compound paths, symbol instances and groups of them (similar groups, #4939: edits inside one group reach the same object in the others; [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); Shift-click to leave a match out ([public source](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)); the Control panel's copy of the options, rotated copies and highlighting matches before the start remain ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)) | | Contextual task bar | done | | diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index 49341cc1b4..180e90bc3e 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -5,7 +5,7 @@ use std::process::Command; use serde_json::Value; -const REV: &str = "f8f8829cc7be65d466d687dae381d1ca60e31242"; +const REV: &str = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"; const PACKAGES: &[(&str, &str, &str)] = &[ ("craft-pdf", "0.1.0", REV), ("krilla", "0.8.2", REV),