mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 02:39:42 +00:00
Merge remote-tracking branch 'origin/main' into fix/import-document-title
This commit is contained in:
@@ -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 |
|
||||
|
||||
Generated
+16
-15
@@ -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",
|
||||
|
||||
+13
-13
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
use windows::Win32::System::Registry::{RRF_RT_REG_SZ, RegGetValueW};
|
||||
use windows::core::PCWSTR;
|
||||
let subkey: Vec<u16> = 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<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
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::<Vec<u16>>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -236,7 +236,8 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option<Vec<Point>> {
|
||||
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);
|
||||
}
|
||||
|
||||
+106
-15
@@ -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<Point>,
|
||||
/// 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<CircularSub> {
|
||||
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<CircularSub> {
|
||||
} 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<Point> {
|
||||
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<Point>,
|
||||
/// For each anchor, the angle (radians, document space) the segment that starts there turns
|
||||
/// through: 0 for a straight segment.
|
||||
turns: Vec<f64>,
|
||||
/// The arcs' extreme points ([`arc_extrema`]), for the drawing extents.
|
||||
extrema: Vec<Point>,
|
||||
}
|
||||
|
||||
/// 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<BulgedSub> {
|
||||
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<f64> = 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<f64>) {
|
||||
/// 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<f64>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
-4
@@ -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<usize> = (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<i64> = polys.iter().map(|i| get(&e[*i], 70).unwrap().parse::<i64>().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<f64> = 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::<Vec<_>>(), &[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<Point> = 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<f64> = 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<f64> = 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]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<f32>,
|
||||
/// 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<f32>,
|
||||
}
|
||||
|
||||
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<f64> = 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<f64> = 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));
|
||||
|
||||
@@ -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<u64> = 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<usize> {
|
||||
@@ -514,6 +531,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
}
|
||||
_ => 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<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
*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<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Value> {
|
||||
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<V
|
||||
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))
|
||||
})?;
|
||||
let m = idx.len();
|
||||
let landed = first_landed.unwrap_or(to);
|
||||
@@ -1073,7 +1126,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
@@ -138,8 +138,12 @@ fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
|
||||
/// What a cut does to one object: its new pieces (none deletes it).
|
||||
type Plan = Vec<(NodeId, Vec<PathData>)>;
|
||||
|
||||
/// 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<Vec<PathData>>) -> Result<Value> {
|
||||
/// What a cut makes of one object: its pieces (`None`: unchanged), or the pathops refusal.
|
||||
type Cut = std::result::Result<Option<Vec<PathData>>, 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<Value> {
|
||||
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<PathData, po::PathOpsError> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
/// `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<Vec<PathData>> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside.
|
||||
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
|
||||
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<Value> {
|
||||
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<f64, String
|
||||
}
|
||||
|
||||
/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side).
|
||||
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData> {
|
||||
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> std::result::Result<Vec<PathData>, 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<SubPath> = 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,21 +304,16 @@ fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Valu
|
||||
finish_same(s, cmd, ids)
|
||||
}
|
||||
|
||||
/// The first character style of the first type object [`each_selected_text`] gives.
|
||||
fn first_text_style(s: &Session) -> Option<CharStyle> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -420,7 +420,10 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
/// 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<u8>, Vec<String>)> {
|
||||
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.
|
||||
|
||||
@@ -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<bool> {
|
||||
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<bool> {
|
||||
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 {
|
||||
|
||||
@@ -334,9 +334,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
}
|
||||
"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)));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -263,7 +263,7 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
|
||||
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
// 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<Option<Strin
|
||||
}
|
||||
|
||||
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
|
||||
/// native document with its save options, whose pages may be left blank).
|
||||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
/// 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<Vec<u8>>,
|
||||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||||
) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -249,11 +249,6 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
if let Some((key, top, inner)) = disagreeing_option(p, is_svg(format)).map_err(|e| bad(cmd, e))? {
|
||||
return Err(bad(cmd, format!("`{key}: {top}` and `options.{key}: {inner}` disagree: give the option once")));
|
||||
}
|
||||
let notes = NATIVE_OPTIONS
|
||||
.iter()
|
||||
.filter(|k| p.get(**k).is_some_and(|v| !v.is_null()) && !reads_option(format, k))
|
||||
.map(|k| format!("`{k}` was not used: {} files don't read it", format.label))
|
||||
.collect();
|
||||
let mut given: Map<String, Value> = match p.get("options") {
|
||||
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
|
||||
None | Some(Value::Null) => Map::new(),
|
||||
@@ -293,6 +288,12 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
if let Some(path) = &path {
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd)?;
|
||||
}
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
/// 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<Encoded> {
|
||||
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<String>) = 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) })
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<String> = 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();
|
||||
|
||||
@@ -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<String> {
|
||||
fn walk(nodes: &[std::sync::Arc<vectorcraft_doc::Node>], depth: usize, out: &mut Vec<String>) {
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<GraphKind> = (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);
|
||||
|
||||
@@ -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<Arc<Node>>) -> Vec<Arc<Node>> {
|
||||
let Some(slot) = g.series_paints.get(i) else { return items };
|
||||
let kept = |o: Option<f32>| 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();
|
||||
|
||||
@@ -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<vectorcraft_doc::Node> {
|
||||
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]
|
||||
|
||||
@@ -439,7 +439,6 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"shape.flare",
|
||||
"shape.polarGrid",
|
||||
"shape.rectangularGrid",
|
||||
"stroke.set",
|
||||
"stroke.setAdvanced",
|
||||
"swatch.edit",
|
||||
"swatch.library.load",
|
||||
|
||||
@@ -338,8 +338,9 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
|
||||
Cow::Owned(doc) => doc,
|
||||
};
|
||||
let mut need: Vec<PlacedDocument> = 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());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {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
|
||||
),
|
||||
|
||||
@@ -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<Value> {
|
||||
/// 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<Vec<(Option<&'a str>, &'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<Vec
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every name a swatch or colour group of `d` uses (they share one namespace, with the registration swatch).
|
||||
fn taken_names(d: &vectorcraft_doc::Document) -> HashSet<String> {
|
||||
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<String>, 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<Value> {
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
// 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<Value> {
|
||||
}
|
||||
};
|
||||
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<Value> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, serde_json::Value>)>,
|
||||
/// 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<NodeId>)>,
|
||||
/// 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<Option<(usize, cmd::gradient::StopOwner)>>,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SubPath> {
|
||||
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<f64> = 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<f64> = 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::<Vec<_>>(), [(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:?}");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<i64>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
/// Its header comments, up to `%%EndComments`.
|
||||
pub header: Vec<u8>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Write `doc` as the editing data of a `.ai` file: every artboard and layer.
|
||||
pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditingOutput, String> {
|
||||
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<u8> {
|
||||
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<Document> {
|
||||
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>), 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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Cms>,
|
||||
pub(super) body: String,
|
||||
pub(super) warnings: Vec<String>,
|
||||
/// 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<PathData>,
|
||||
/// Symbols, live objects and pattern tiles being written inside one another.
|
||||
nest: u32,
|
||||
/// The XML ids objects are named with so far.
|
||||
ids: HashSet<String>,
|
||||
/// The number the next object named after each XML id tries first.
|
||||
next: HashMap<String, u64>,
|
||||
/// The document's brushes, read the first time a brushed stroke is written.
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
/// 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<Arc<Node>> = 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<Node>]) {
|
||||
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<Node>]) {
|
||||
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<image::DynamicImage> {
|
||||
let reader = || -> image::ImageResult<image::ImageReader<std::io::Cursor<&[u8]>>> {
|
||||
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]))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<u8>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Node>) {
|
||||
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() {
|
||||
|
||||
@@ -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::<f64>())
|
||||
&& (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::<f64>()
|
||||
&& (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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<NodeId>) {
|
||||
if let Some(children) = n.children_mut() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<usize>();
|
||||
self.alloc(copied)?;
|
||||
self.spend((copied / 256) as u64)?;
|
||||
Some((chain.clone(), from.clone()))
|
||||
}
|
||||
Stream::Buf { .. } => None,
|
||||
};
|
||||
match pending {
|
||||
|
||||
@@ -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<Rect> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>), 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<Rect>, 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<Rect>| 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<Rect>, 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<NodeId>,
|
||||
shown: &[Vec<Arc<Node>>],
|
||||
reflow: bool,
|
||||
) -> Option<(Document, Vec<String>)> {
|
||||
/// 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<Node>], 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<String>)> = 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<String> = 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<Node>], out: &mut Vec<Rect>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Rect>, art: Option<Rect>, 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<Rect>, art: Option<Rect>, page: Option<&Document>, unit: f64, canvas_scale: f64) -> Option<f64> {
|
||||
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 {
|
||||
|
||||
@@ -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<Rect> {
|
||||
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<Rect>)> {
|
||||
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<bool>) {
|
||||
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
|
||||
|
||||
@@ -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>), 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)
|
||||
|
||||
@@ -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<Made>]) -> 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<Option<Made>>, pages: [&[Vec<Arc<Node>>]; 2], warn: &mut Vec<String>) -> Vec<Content> {
|
||||
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<String> =
|
||||
content.iter().map(|c| if let Content::Made(node, ..) = c { spelling(&plain_text(node)) } else { String::new() }).collect();
|
||||
let made_type = |o: &[Arc<Node>]| covered(o, &content, |_| true) && has_made_words(o, &content, &spelled);
|
||||
let mut spare: Vec<Vec<Arc<Node>>> = 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<Vec<Arc<Node>>>;
|
||||
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<Vec<Arc<Node>>>) {
|
||||
// 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<NodeId>, Rect, Vec<usize>, Vec<usize>);
|
||||
let mut copies: Vec<Copy> = vec![];
|
||||
let mut by_group: std::collections::HashMap<Option<NodeId>, 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<usize>, Vec<usize>)> = 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<usize> = boxes.iter().flat_map(|b| b.1.iter().copied()).collect();
|
||||
let others: Vec<usize> = 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<Vec<Arc<Node>>> = 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<Node>], 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<Arc<Node>>]) -> Option<us
|
||||
|
||||
/// What goes into each of `infos`' slots when the file's text document can't make the type of every
|
||||
/// slot that shows: type made from the text document (`made`) where it can, else the page's type that
|
||||
/// the text document puts where the page has it, else the page's type in painting order. The page's
|
||||
/// the text document puts where the page has it, else the page's type in a copy that a layer's or
|
||||
/// group's look draws (see [`fill_look_slots`]), else the page's type in painting order. The page's
|
||||
/// type that lies where made type draws and has its words is that type as the page draws it; the
|
||||
/// rest goes only into slots without made type.
|
||||
pub(super) fn plan(
|
||||
@@ -231,17 +377,6 @@ pub(super) fn plan(
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect();
|
||||
// Has made type that draws where `object` lies every word of it ([`spelling`]), in any order?
|
||||
let made_words = |object: &[Arc<Node>], 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<Arc<Node>>> = 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<usize> =
|
||||
infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect();
|
||||
|
||||
@@ -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<PathData>,
|
||||
/// 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<Rect>,
|
||||
/// The group it is in.
|
||||
pub(super) group: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// The text slots of the layers and objects, in order.
|
||||
pub(super) fn collect_slots(nodes: &[Arc<Node>], shown: bool, out: &mut Vec<SlotInfo>) {
|
||||
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<Node>],
|
||||
shown: bool,
|
||||
groups: &mut Vec<&'a Node>,
|
||||
boxes: &mut std::collections::HashMap<NodeId, Option<Rect>>,
|
||||
out: &mut Vec<SlotInfo>,
|
||||
) {
|
||||
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<Arc<Node>>, infos: &mut Vec<SlotInfo>, 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<Arc<Node>>) {
|
||||
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<Node>]) -> Option<Rect> {
|
||||
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<Node>]| {
|
||||
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::<Vec<_>>();
|
||||
// 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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: <Layer> (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::<Vec<_>>()
|
||||
.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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>();
|
||||
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::<Vec<_>>(), ["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<Vec<String>> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+23
-1
@@ -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<Item = PathEl>, 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]
|
||||
|
||||
+186
-42
@@ -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<Self> {
|
||||
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<Self> {
|
||||
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] {
|
||||
|
||||
@@ -75,7 +75,7 @@ pub(crate) fn checksum(header: &[u8]) -> u16 {
|
||||
fn flatten(bp: &BezPath, tol: f64) -> Vec<(Vec<Point>, bool)> {
|
||||
let mut out: Vec<(Vec<Point>, bool)> = vec![];
|
||||
let mut cur: Vec<Point> = 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));
|
||||
|
||||
@@ -143,9 +143,20 @@ pub(crate) fn eps_for<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> 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<Item = Point> {
|
||||
use kurbo::PathEl::*;
|
||||
let v: [Option<Point>; 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<Contours, PathOpsError> {
|
||||
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<Vec<bool>> {
|
||||
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<PathData, PathOpsError> {
|
||||
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<PathData, PathOpsError> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<bool> =
|
||||
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<kurbo::Point> {
|
||||
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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<i64>,
|
||||
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
|
||||
pub native: Option<Vec<u8>>,
|
||||
/// The editing copy of the art for every page's `PieceInfo`, as a `.ai` file carries it.
|
||||
pub private_data: Option<PrivateData>,
|
||||
/// 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<Thumbnail>,
|
||||
|
||||
@@ -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<u8>,
|
||||
/// What the `AIMetaData` stream holds.
|
||||
pub metadata: Vec<u8>,
|
||||
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<u8>, p: &PrivateData, modified: i64) -> Result<Vec<u8>, 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::<Option<Vec<usize>>>();
|
||||
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<u32> = 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<u8> {
|
||||
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<String> = 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')");
|
||||
}
|
||||
}
|
||||
@@ -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<u8>, 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);
|
||||
|
||||
@@ -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<String>) -> Result<(Vec<Layout>, Vec<TileGrid>), PdfError> {
|
||||
@@ -174,6 +178,10 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec<Str
|
||||
let origin = set.tile_origin.placed.then_some((set.tile_origin.x, set.tile_origin.y));
|
||||
let (mut pages, mut grids) = (vec![], vec![]);
|
||||
for (artboard, r) in regions(doc, set)? {
|
||||
// The job is refused past MAX_PAGES (copies and inks only add to it): stop making pages here.
|
||||
if pages.len() > 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<Str
|
||||
let (x, y) = (grid.0 + c * step.0, grid.1 + rw * step.1);
|
||||
Rect::new(x, y, x + tile.0, y + tile.1)
|
||||
};
|
||||
if pages.len() + pick.len() > 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));
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -120,8 +120,15 @@ struct Cache {
|
||||
|
||||
static CACHE: Mutex<Option<Cache>> = 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<Option<HashMap<String, String>>> = 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -370,14 +370,14 @@ impl<'l> Acc<'l> {
|
||||
pub fn problem(&mut self, code: &'static str, line: Option<usize>, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<String>::new(), "turned off again");
|
||||
|
||||
@@ -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<Vec<(f64, f64)>> {
|
||||
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 {
|
||||
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/gdef>), 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<Vec<f64>> {
|
||||
/// 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<Vec<f64>> {
|
||||
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::<Option<Vec<f64>>>()?;
|
||||
(!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<Vec<f64>> {
|
||||
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<Vec<f64>> {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -76,9 +76,23 @@ pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<Strin
|
||||
/// The most distinct legacy family (and style) names of a font used as keys.
|
||||
pub(super) const MAX_LEGACY_NAMES: usize = 64;
|
||||
|
||||
/// Records can share their encoded bytes, so a small table names many long strings: stop at this
|
||||
/// many records or decoded bytes (real fonts have a few dozen short names per id).
|
||||
const MAX_NAME_RECORDS: usize = 1024;
|
||||
const MAX_NAME_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Every localized string of `id`, with its language.
|
||||
pub(super) fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option<String>, 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<Item = char> + '_ {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,175 @@ fn db_with(font: Option<Vec<u8>>) -> FontDb {
|
||||
db
|
||||
}
|
||||
|
||||
/// Put a contour-point caret on the existing test ligature without changing its outline.
|
||||
fn contour_caret_font(point_index: u16) -> Vec<u8> {
|
||||
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<u16> {
|
||||
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<u8>)]) -> Vec<u8> {
|
||||
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<u8> = 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<u8> {
|
||||
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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user