mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 23:24:12 -04:00
Compare commits
293
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10190753ef | ||
|
|
ea1e60d8ab | ||
|
|
e4a3adcea0 | ||
|
|
47f3437e75 | ||
|
|
5e89b11685 | ||
|
|
73e0f855c4 | ||
|
|
9bbbf9fdb7 | ||
|
|
df857eea4a | ||
|
|
970e62f27d | ||
|
|
f365468684 | ||
|
|
38b9cd44b1 | ||
|
|
3f16fabd89 | ||
|
|
a880b3eb0a | ||
|
|
445550ea29 | ||
|
|
1505384501 | ||
|
|
e93a73f60b | ||
|
|
9de4b40241 | ||
|
|
589f0eaf0f | ||
|
|
a28c3fdf27 | ||
|
|
e7233ef0c5 | ||
|
|
974bca0c9d | ||
|
|
455c0df29d | ||
|
|
f63ba14378 | ||
|
|
e034f248d9 | ||
|
|
3d7f0bb81e | ||
|
|
c26d809ea2 | ||
|
|
205f65b7a3 | ||
|
|
a6c0911d72 | ||
|
|
4e7b435ff0 | ||
|
|
16d39d7710 | ||
|
|
3b5b185947 | ||
|
|
3d24c663d2 | ||
|
|
d288319e26 | ||
|
|
3c0a781e1c | ||
|
|
6da6b4440f | ||
|
|
087e38aafc | ||
|
|
0395729b31 | ||
|
|
1e45fe99f7 | ||
|
|
8b03e81634 | ||
|
|
9a28b1f049 | ||
|
|
edca5e9eb0 | ||
|
|
b3177919a9 | ||
|
|
50be70a4fe | ||
|
|
866bf61116 | ||
|
|
2e142583b3 | ||
|
|
8abb1d1c71 | ||
|
|
02f5bc8666 | ||
|
|
ae6cf1a729 | ||
|
|
a65cae367f | ||
|
|
f39c9544eb | ||
|
|
b5a99542eb | ||
|
|
888af39881 | ||
|
|
3f74e38fea | ||
|
|
548fad4b59 | ||
|
|
625cba6f0a | ||
|
|
4e8064cfea | ||
|
|
1be08ffbd4 | ||
|
|
32072c77fa | ||
|
|
368fa3def2 | ||
|
|
8bf7ffe39d | ||
|
|
ffeb37f481 | ||
|
|
54c8a7f2bd | ||
|
|
1673b3b747 | ||
|
|
705eda5dc8 | ||
|
|
74c9226c3f | ||
|
|
c1f96cc6f8 | ||
|
|
4e27fccdda | ||
|
|
f44dd739b2 | ||
|
|
56d34d4ced | ||
|
|
4b7969a05c | ||
|
|
f7dde42990 | ||
|
|
ecbcf92f3a | ||
|
|
44e1c3af62 | ||
|
|
06790288e1 | ||
|
|
b37522bbb3 | ||
|
|
2d85603bcc | ||
|
|
1abc5d4066 | ||
|
|
6a394c3a6f | ||
|
|
4d9749f4c7 | ||
|
|
efe2429618 | ||
|
|
8924b24e94 | ||
|
|
6fd1f566e1 | ||
|
|
6beb68f934 | ||
|
|
2bad646821 | ||
|
|
e195fc94e2 | ||
|
|
997dd8d2bd | ||
|
|
4ee7173f11 | ||
|
|
3d9656024e | ||
|
|
5b0e60f865 | ||
|
|
03bf93c203 | ||
|
|
165fea7f9b | ||
|
|
4a29fd4112 | ||
|
|
e20c330bd3 | ||
|
|
a7ee168efe | ||
|
|
5b919c0529 | ||
|
|
48792842ec | ||
|
|
97c173beb9 | ||
|
|
d548b4ff74 | ||
|
|
5e9df83dd4 | ||
|
|
524ecee6ca | ||
|
|
7fd4b33dc5 | ||
|
|
26b9cf84ad | ||
|
|
9b2173611f | ||
|
|
b50fbc08cf | ||
|
|
44c306f142 | ||
|
|
30360a67a0 | ||
|
|
24fadbd322 | ||
|
|
72dce93463 | ||
|
|
40e40bf8cb | ||
|
|
ab330e4adc | ||
|
|
dc38eb6bee | ||
|
|
6b10b159b9 | ||
|
|
7c9edfaf7f | ||
|
|
159e20053c | ||
|
|
4062e74ff3 | ||
|
|
fffc8418d9 | ||
|
|
00f557e02e | ||
|
|
0490ca0788 | ||
|
|
45f6e99eb1 | ||
|
|
329916c2b8 | ||
|
|
2f3ccb7b2c | ||
|
|
dbf6258eae | ||
|
|
4a4c8c9d49 | ||
|
|
6e091a33f5 | ||
|
|
6903bb7ef8 | ||
|
|
b35a6b1573 | ||
|
|
a99d81e9aa | ||
|
|
d73946ba46 | ||
|
|
bd12b48e02 | ||
|
|
cc1a660a6d | ||
|
|
a1d8952bc8 | ||
|
|
4bb6f32911 | ||
|
|
30d1948f51 | ||
|
|
c4ac0c5df5 | ||
|
|
22b6f2a89c | ||
|
|
9a72eadc51 | ||
|
|
781e62b9cc | ||
|
|
3a1d407f1a | ||
|
|
def14fc8b2 | ||
|
|
0919e54785 | ||
|
|
1b7cfc8bee | ||
|
|
49d69d48e5 | ||
|
|
0c41f0af55 | ||
|
|
87fdd4a370 | ||
|
|
9097c4a564 | ||
|
|
a5778fcfca | ||
|
|
bd6d076484 | ||
|
|
cee30f05b6 | ||
|
|
b0be9edabb | ||
|
|
896f3aa4a1 | ||
|
|
ab1f9f2b92 | ||
|
|
2e2e6a2f8e | ||
|
|
a1ceed08fc | ||
|
|
4f9b620dc6 | ||
|
|
439d32fcf8 | ||
|
|
cc499d7e8e | ||
|
|
28ac044c74 | ||
|
|
648a8f5104 | ||
|
|
2d7004f972 | ||
|
|
2f27759bcc | ||
|
|
b3ccdf01a7 | ||
|
|
f73a3df974 | ||
|
|
fd50bd6f84 | ||
|
|
a98ca04196 | ||
|
|
76399175eb | ||
|
|
dee53e3413 | ||
|
|
cbf2d9cc43 | ||
|
|
00fe2be469 | ||
|
|
385f1588fc | ||
|
|
fe8b8c8106 | ||
|
|
fc996e0e03 | ||
|
|
052bbb14b4 | ||
|
|
f553be25f7 | ||
|
|
e56e40dd85 | ||
|
|
bbaf7b02c3 | ||
|
|
5d4cac31cf | ||
|
|
22c83b5951 | ||
|
|
7141932dfb | ||
|
|
bbc9bd3de2 | ||
|
|
423bc71705 | ||
|
|
c60ad8dcfa | ||
|
|
6ee972c4da | ||
|
|
a8bdf5043f | ||
|
|
b0d5ab3f68 | ||
|
|
c49a1faa4b | ||
|
|
5498b62a3d | ||
|
|
a65d3f2313 | ||
|
|
81dca05eb1 | ||
|
|
a2e7a2a562 | ||
|
|
3b8e1aef43 | ||
|
|
8862058388 | ||
|
|
b833187ff8 | ||
|
|
2bf0595f09 | ||
|
|
388c653cd1 | ||
|
|
8735170a34 | ||
|
|
6883235bad | ||
|
|
0414a13ace | ||
|
|
f1dc5e50a3 | ||
|
|
4ae1b3d51a | ||
|
|
36eed1ac77 | ||
|
|
5a7689c3b4 | ||
|
|
af520a7cf3 | ||
|
|
b11b41d5f3 | ||
|
|
6ee357a6d4 | ||
|
|
bb4767fca5 | ||
|
|
b1befcfa02 | ||
|
|
71b5c9c45c | ||
|
|
5359c92a19 | ||
|
|
d6e64cb080 | ||
|
|
fa3d6d9298 | ||
|
|
5013189fd3 | ||
|
|
945fa338d0 | ||
|
|
5a258e4c73 | ||
|
|
7fa5173962 | ||
|
|
379b033728 | ||
|
|
de1182fbe3 | ||
|
|
0834be2989 | ||
|
|
fd8459592d | ||
|
|
ae13bd7dee | ||
|
|
b14fa518fb | ||
|
|
3d4d34fc15 | ||
|
|
15b041be81 | ||
|
|
e4db022f49 | ||
|
|
11eb27b127 | ||
|
|
e5b732baf5 | ||
|
|
4137d6d286 | ||
|
|
317ca00ccc | ||
|
|
438c6194a6 | ||
|
|
56b7dc300d | ||
|
|
d792e7b2be | ||
|
|
e87534342b | ||
|
|
db42a68e3f | ||
|
|
2282122272 | ||
|
|
7c39ce026f | ||
|
|
a98dd3e552 | ||
|
|
bf3e6adc50 | ||
|
|
c43ead202f | ||
|
|
152fc18d9b | ||
|
|
bfc3f5251a | ||
|
|
f639f449d7 | ||
|
|
aef00190ed | ||
|
|
3dd8c3ec28 | ||
|
|
f3863ef50f | ||
|
|
6a4a739178 | ||
|
|
1fbec667ec | ||
|
|
1388b8ec1a | ||
|
|
fe9ceb3dfa | ||
|
|
e33094e11d | ||
|
|
633ad1d749 | ||
|
|
f832625e0b | ||
|
|
2dfe6613ad | ||
|
|
9219e41440 | ||
|
|
490c8da696 | ||
|
|
9bd9f570ae | ||
|
|
66655c67c2 | ||
|
|
17fbf6ad6a | ||
|
|
757eaf9afa | ||
|
|
d275fbf725 | ||
|
|
e72735bcf9 | ||
|
|
dd9c285252 | ||
|
|
ae3a8fe4c3 | ||
|
|
60f4c4f145 | ||
|
|
79dca4c8f9 | ||
|
|
afd66ad695 | ||
|
|
2f893566ee | ||
|
|
caa6962508 | ||
|
|
f64db69b53 | ||
|
|
2b8a11876a | ||
|
|
dc9d751015 | ||
|
|
6066b4b8c7 | ||
|
|
718c65c855 | ||
|
|
a4f5ae3b16 | ||
|
|
de917a138a | ||
|
|
e66bca39b2 | ||
|
|
4e4723699d | ||
|
|
3bb0f07a4b | ||
|
|
92a8a45d32 | ||
|
|
5f6c7b8da9 | ||
|
|
14a70449f7 | ||
|
|
c8082facb1 | ||
|
|
430741f074 | ||
|
|
bd9a508d49 | ||
|
|
b15230bfb5 | ||
|
|
15434b6db4 | ||
|
|
84e76c1d70 | ||
|
|
ede08590a0 | ||
|
|
92f295d2a2 | ||
|
|
cc42a333b7 | ||
|
|
95f503367f | ||
|
|
a339433c4b | ||
|
|
34cde7c3db | ||
|
|
0ac1f133b0 | ||
|
|
b3268a829d |
No files matched your search
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
|
||||
Generated
+41
-28
@@ -712,6 +712,15 @@ dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -4565,10 +4574,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"chrono",
|
||||
"clipboard-win",
|
||||
"craft-print",
|
||||
"craft-window",
|
||||
@@ -4603,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4614,7 +4624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4626,7 +4636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4642,7 +4652,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4664,7 +4674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4676,8 +4686,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4694,7 +4705,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4710,7 +4721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
@@ -4748,7 +4759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4771,7 +4782,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4786,7 +4797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4798,7 +4809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4817,7 +4828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4831,7 +4842,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4843,9 +4854,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4873,7 +4885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4887,7 +4899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4914,14 +4926,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
@@ -4946,7 +4958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -4964,7 +4976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4978,13 +4990,14 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vello_cpu 0.2.0",
|
||||
"windows-registry",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4992,7 +5005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -5008,7 +5021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -5020,7 +5033,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"craft-ui",
|
||||
@@ -5056,7 +5069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6094,7 +6107,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -56,6 +56,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
crc32fast = "1.5"
|
||||
# tests: a TCP port bound without listening (connections to it are refused)
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" width="128" height="128" alt="VectorCraft icon: an engraved dragon on a red tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">VectorCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -238,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
|
||||
and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
|
||||
pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in
|
||||
(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
|
||||
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
@@ -253,7 +258,7 @@ The scores are self-assessed from our code against public documentation;
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- most of the raster effects (9 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper under Sketch) and the Effect Gallery;
|
||||
- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in);
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
+15
-4
@@ -9,7 +9,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (COLR colour fonts (v0 and v1) draw and export as vector art; previously: G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview; previously: colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 54/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 82% | 8–14 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
@@ -123,12 +123,18 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: the 10 remaining filters (7 Artistic, 2 Sketch, 1 more) and the Effect Gallery, 8–14 h.
|
||||
5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph.
|
||||
- 2026-10-22: The Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step. Filter thumbnails and a preview area inside the dialog remain ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Raster effects 82% → 85% (estimated); headline numbers unchanged.
|
||||
- 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged.
|
||||
|
||||
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
|
||||
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
|
||||
- 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged.
|
||||
|
||||
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
|
||||
|
||||
@@ -142,6 +148,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
|
||||
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
|
||||
| 2026-10-21 | Affinity import (AF.5), brought over from DesignCraft's (storytold/designcraft#291): master pages show on the pages that use them (a locked layer at the bottom); tables open as a text frame per cell and a line per edge; each paragraph keeps its alignment, indents, spacing and leading; frame text takes its text scale, not its frame's; drop shadows and outer glows become live effects, outlines strokes, colour and gradient overlays are baked into the paint; embedded Affinity documents open as editable content, and their artboards no longer become artboards; large pixel layers come in at a reduced resolution; text placed as Affinity places it (lines of differently aligned paragraphs, a frame's first baseline at cap height, last lines whose descenders hang out of the frame), underlines and strikethrough, line breaks inside paragraphs, table cells taller than their row, no stroke on shapes whose stroke style is None, CMYK pixel layers (phone photos in CMYK documents) shown through the document's own CMYK profile instead of the profile-free formula and its blue cast, outlines on stroked shapes. Headline numbers unchanged. Hostile files stay bounded (one budget for the whole read, embedded documents included; overlay work; master page copies), covered by the import fuzzer and limit tests. |
|
||||
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
@@ -189,6 +196,10 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. |
|
||||
| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. |
|
||||
| 2026-10-22 | minor | Progress log row for colour emoji fonts with bitmap glyphs; headline numbers unchanged. |
|
||||
| 2026-10-22 | minor | Effects: Sketch › Conté Crayon; measured Photoshop-style filters 57/57, the Effect Gallery remains (G2 estimate 4–6 h). |
|
||||
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
|
||||
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
|
||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! `vectorcraft-cli`: VectorCraft from the command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
//! vectorcraft-cli info FILE
|
||||
@@ -68,19 +68,24 @@ const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
USAGE:
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
--automation-read-root / --automation-write-root confine the files the agent's commands read
|
||||
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
|
||||
DIR, links followed; a root left out grants none of its access. They imply --headless: a
|
||||
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
||||
DIR, links followed; each may be given more than once, a path inside any of its folders being
|
||||
allowed, and a kind left out grants none of its access. They imply --headless: a running app
|
||||
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
--commands reads steps from FILE, or from standard input for -, where it stands among the
|
||||
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
|
||||
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
|
||||
Errors name the step, counting from 0 as \"$N\" references do.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
@@ -177,7 +182,7 @@ fn with_stores(mut h: Headless) -> Headless {
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);
|
||||
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
|
||||
@@ -190,10 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
"--headless" => headless = true,
|
||||
"--automation-read-root" | "--automation-write-root" => {
|
||||
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
|
||||
if slot.replace(dir).is_some() {
|
||||
return Err(format!("{flag} is given twice: it takes one folder"));
|
||||
}
|
||||
if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
|
||||
}
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
@@ -201,7 +203,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
|
||||
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
|
||||
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
|
||||
.into());
|
||||
@@ -209,8 +213,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let backend: Box<dyn Backend> = if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
@@ -338,28 +341,127 @@ enum Step {
|
||||
Export(String),
|
||||
}
|
||||
|
||||
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
|
||||
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// Most steps one `run --commands` takes.
|
||||
const MAX_COMMANDS_STEPS: usize = 100_000;
|
||||
|
||||
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
|
||||
/// [`MAX_COMMANDS_BYTES`].
|
||||
fn read_commands(source: &str) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
let read = if source == "-" {
|
||||
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
|
||||
} else {
|
||||
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
|
||||
};
|
||||
read.map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
|
||||
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
|
||||
}
|
||||
|
||||
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
|
||||
/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object
|
||||
/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them
|
||||
/// (the `N` of `"$N"`), which an error names with the entry's place in the text.
|
||||
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<Step>, String> {
|
||||
// Notepad on Windows starts UTF-8 files with a byte order mark.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
|
||||
// Each entry with where it is in the text: its line for JSON Lines.
|
||||
let mut entries: Vec<(Option<usize>, Value)> = vec![];
|
||||
if text.trim_start().starts_with('[') {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
|
||||
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
|
||||
if items.len() > MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
entries.extend(items.into_iter().map(|v| (None, v)));
|
||||
} else {
|
||||
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
|
||||
if entries.len() == MAX_COMMANDS_STEPS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
|
||||
entries.push((Some(n + 1), v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, (line, v))| {
|
||||
command_step(v).map_err(|e| {
|
||||
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
|
||||
format!("--commands {source}: step {} ({place}): {e}", first + k)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`.
|
||||
fn command_step(v: Value) -> Result<Step, String> {
|
||||
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) };
|
||||
let id = match (o.remove("command"), o.remove("id")) {
|
||||
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
|
||||
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
|
||||
(None, None) => return Err("no `command`".into()),
|
||||
_ => return Err("`command` must be a string, a command id".into()),
|
||||
};
|
||||
let params = match o.remove("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(p @ Value::Object(_)) => p,
|
||||
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
|
||||
};
|
||||
if let Some(key) = o.keys().next() {
|
||||
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
|
||||
}
|
||||
Ok(Step::Cmd(id, params))
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input: Option<String> = None;
|
||||
let mut steps: Vec<Step> = vec![];
|
||||
// The step of the `--cmd` just given, which a `--params` sets.
|
||||
let mut params_for: Option<usize> = None;
|
||||
let mut stdin_read = false;
|
||||
let mut scale = 1.0;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
||||
match a.as_str() {
|
||||
"--in" | "-i" => input = Some(val("a file")?),
|
||||
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
|
||||
"--cmd" | "-c" => {
|
||||
steps.push(Step::Cmd(val("a command id")?, json!({})));
|
||||
params_for = Some(steps.len() - 1);
|
||||
}
|
||||
"--params" | "-p" => {
|
||||
let raw = val("JSON")?;
|
||||
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
|
||||
if !p.is_object() {
|
||||
return Err(format!("--params must be a JSON object, got {raw}"));
|
||||
}
|
||||
match steps.last_mut() {
|
||||
match params_for.and_then(|i| steps.get_mut(i)) {
|
||||
Some(Step::Cmd(_, params)) => *params = p,
|
||||
_ => return Err("--params must follow a --cmd".into()),
|
||||
}
|
||||
}
|
||||
"--export" | "-o" => steps.push(Step::Export(val("a file")?)),
|
||||
"--commands" => {
|
||||
let source = val("a file, or - for standard input")?;
|
||||
if source == "-" && std::mem::replace(&mut stdin_read, true) {
|
||||
return Err("--commands - is given twice: standard input is read once".into());
|
||||
}
|
||||
let text = read_commands(&source)?;
|
||||
let read = parse_commands(&source, &text, steps.len())?;
|
||||
steps.extend(read);
|
||||
params_for = None;
|
||||
}
|
||||
"--export" | "-o" => {
|
||||
steps.push(Step::Export(val("a file")?));
|
||||
params_for = None;
|
||||
}
|
||||
"--scale" | "-s" => {
|
||||
let raw = val("a number")?;
|
||||
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
|
||||
@@ -394,10 +496,12 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
|
||||
let mut results = vec![];
|
||||
for step in steps {
|
||||
// The step's index, as `"$N"` names its result.
|
||||
let n = results.len();
|
||||
let r = match step {
|
||||
Step::Cmd(id, params) => {
|
||||
let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("{id}: {e}"))?;
|
||||
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
|
||||
let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("step {n} ({id}): {e}"))?;
|
||||
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?;
|
||||
emit(json!({"step": "cmd", "command": id, "result": r}))?;
|
||||
r
|
||||
}
|
||||
@@ -407,7 +511,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -23,13 +23,16 @@ impl Rng {
|
||||
}
|
||||
}
|
||||
|
||||
/// The synthetic document's page, in points.
|
||||
const SYNTHETIC_PAGE: (f64, f64) = (1600.0, 1200.0);
|
||||
|
||||
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
|
||||
pub fn synthetic(n: usize) -> Result<Document, String> {
|
||||
let mut d = Document::new(1600.0, 1200.0);
|
||||
let mut d = Document::new(SYNTHETIC_PAGE.0, SYNTHETIC_PAGE.1);
|
||||
let l = d.layers.first().map(|l| l.id);
|
||||
let mut r = Rng(42);
|
||||
for i in 0..n {
|
||||
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
|
||||
let (x, y, s) = (r.next() * SYNTHETIC_PAGE.0, r.next() * SYNTHETIC_PAGE.1, 3.0 + r.next() * 22.0);
|
||||
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
|
||||
let id = d.alloc_id();
|
||||
let mut node = match i % 3 {
|
||||
@@ -296,6 +299,27 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
|
||||
note("after rendering the photos with 3 renderers");
|
||||
}
|
||||
// An SVG with one embedded picture opened from its text, as picture-heavy SVGs come: the
|
||||
// importer's passes over the text before usvg reads it, most of which is the data URL. The
|
||||
// picture is the synthetic page drawn at 1.25x (about 2 MB as a PNG; the row names its size);
|
||||
// the hidden rectangle is a marker, so the prepass walks the tree as it does for a file that
|
||||
// has one, instead of returning at its first check.
|
||||
{
|
||||
let k = 1.25;
|
||||
let (w, h) = ((SYNTHETIC_PAGE.0 * k).round() as u32, (SYNTHETIC_PAGE.1 * k).round() as u32);
|
||||
let photo = Renderer::new().render(&synthetic(3000)?, w, h, Affine::scale(k), &opts).to_png()?;
|
||||
let data = vectorcraft_svg::base64_encode(&photo);
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><image width="{w}" height="{h}" href="data:image/png;base64,{data}"/><rect width="100" height="100" style="display:none"/></svg>"#
|
||||
);
|
||||
// One import checked first, so a failure is reported rather than timed; the same text
|
||||
// imports the same way each time. Its document is kept for the memory reading.
|
||||
let imported = vectorcraft_svg::import_with_report(&svg).map_err(|e| e.to_string())?;
|
||||
let ms = median_ms(5, || drop(vectorcraft_svg::import_with_report(&svg)));
|
||||
rows.push((format!("import SVG, one embedded {} KB PNG", photo.len() / 1024), ms));
|
||||
note("after the import of an embedded picture");
|
||||
drop(imported);
|
||||
}
|
||||
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
|
||||
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
|
||||
let doc = synthetic(n)?;
|
||||
|
||||
@@ -84,11 +84,125 @@ fn scale_sizes_raster_exports_only() {
|
||||
fn run_reports_errors() {
|
||||
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing"));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
|
||||
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
}
|
||||
|
||||
/// The JSON lines `run` printed.
|
||||
fn json_lines(stdout: &[u8]) -> Vec<Value> {
|
||||
String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
|
||||
}
|
||||
|
||||
/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
|
||||
/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
|
||||
/// placed file's `dataBase64` longer than a Windows command line holds fits.
|
||||
#[test]
|
||||
fn run_reads_steps_from_a_commands_file() {
|
||||
let steps = tmp("steps.json");
|
||||
let svg = tmp("steps.svg");
|
||||
// Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
|
||||
let padding = "x".repeat(40_000);
|
||||
let placed = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><!-- {padding} --><rect width="40" height="20" fill="#118833"/></svg>"##
|
||||
);
|
||||
let data = vectorcraft_format::base64_encode(placed.as_bytes());
|
||||
assert!(data.len() > 32_767);
|
||||
let file = json!([
|
||||
{"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
|
||||
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
|
||||
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
|
||||
]);
|
||||
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
|
||||
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines = json_lines(&out.stdout);
|
||||
let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
|
||||
assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
|
||||
// `$2` in the file and in the `--cmd` after it is the placed art.
|
||||
assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
|
||||
let written = std::fs::read_to_string(&svg).unwrap();
|
||||
assert!(written.contains("118833") && written.contains("barcode"), "{written}");
|
||||
}
|
||||
|
||||
/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
|
||||
/// CRLF line ends are fine, and the first step may be `file.new`.
|
||||
#[test]
|
||||
fn run_reads_json_lines_from_standard_input() {
|
||||
let svg = tmp("stdin.svg");
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
|
||||
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines = json_lines(&out.stdout);
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert_eq!(lines[0]["command"], "file.new");
|
||||
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
|
||||
}
|
||||
|
||||
/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
|
||||
/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
|
||||
/// refused; a step that fails names its number.
|
||||
#[test]
|
||||
fn run_refuses_malformed_commands_naming_the_step() {
|
||||
let fails = |args: &[&str]| {
|
||||
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
|
||||
};
|
||||
let bad = |name: &str, text: &[u8], wanted: &[&str]| {
|
||||
let path = tmp(name);
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
|
||||
assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
|
||||
assert_eq!(printed, 0, "{name}: nothing runs");
|
||||
};
|
||||
bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
|
||||
bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
|
||||
bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
|
||||
bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
|
||||
bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
|
||||
bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
|
||||
bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
|
||||
bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["step 2 (line 3)"]);
|
||||
bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
|
||||
bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
|
||||
let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
|
||||
bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
|
||||
// Past 64 MiB (a sparse file: nothing is written).
|
||||
let huge = tmp("huge.json");
|
||||
std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
|
||||
let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
|
||||
assert!(e.contains("more than 64 MiB"), "{e}");
|
||||
let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
|
||||
assert!(e.contains("missing.json"), "{e}");
|
||||
// `--params` sets the `--cmd` just before it, never a step of the file.
|
||||
let ok = tmp("ok.json");
|
||||
std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
|
||||
let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
|
||||
assert!(e.contains("--params must follow a --cmd"), "{e}");
|
||||
let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
|
||||
assert!(e.contains("standard input is read once"), "{e}");
|
||||
// A step that fails is named by its number, after the steps before it ran.
|
||||
let failing = tmp("failing.json");
|
||||
std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
|
||||
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
|
||||
assert!(e.contains("step 2 (nope.nothing)"), "{e}");
|
||||
assert_eq!(printed, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_headless_over_stdio() {
|
||||
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
@@ -130,6 +244,58 @@ fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
|
||||
automation_roots(&empty_dir(dir).join("roots"));
|
||||
}
|
||||
|
||||
/// #3546: each root flag may be given more than once: a path inside any of the read roots opens,
|
||||
/// one inside any of the write roots is written, and everything else, a write root's sibling read
|
||||
/// root included, is refused.
|
||||
#[test]
|
||||
fn mcp_automation_roots_take_several_folders() {
|
||||
let base = tmp("several-roots");
|
||||
for d in ["in", "in2", "out", "out2", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
|
||||
for f in ["in/a.svg", "in2/b.svg", "outside/c.svg"] {
|
||||
std::fs::write(base.join(f), svg).unwrap();
|
||||
}
|
||||
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
|
||||
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
|
||||
let mut child = Command::new(BIN)
|
||||
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
|
||||
.args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
|
||||
let calls = [
|
||||
(call(1, "open_file", json!({"path": file("in", "a.svg")})), false),
|
||||
(call(2, "open_file", json!({"path": file("in2", "b.svg")})), false),
|
||||
(call(3, "open_file", json!({"path": file("outside", "c.svg")})), true),
|
||||
(call(4, "export", json!({"path": file("out", "a.png")})), false),
|
||||
(call(5, "export", json!({"path": file("out2", "b.png")})), false),
|
||||
(call(6, "export", json!({"path": file("outside", "c.png")})), true),
|
||||
(call(7, "export", json!({"path": file("in2", "d.png")})), true),
|
||||
(call(8, "open_file", json!({"path": file("out2", "b.png")})), true),
|
||||
];
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for (m, _) in &calls {
|
||||
writeln!(stdin, "{m}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("files confined: read ") && stderr.contains("; write "), "{stderr}");
|
||||
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
|
||||
assert_eq!(errors, calls.iter().map(|(_, e)| *e).collect::<Vec<_>>(), "{replies:?}");
|
||||
assert!(replies[2]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read roots"), "{}", replies[2]);
|
||||
assert!(base.join("out/a.png").is_file() && base.join("out2/b.png").is_file());
|
||||
assert!(!base.join("outside/c.png").exists() && !base.join("in2/d.png").exists());
|
||||
}
|
||||
|
||||
fn automation_roots(base: &std::path::Path) {
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
@@ -153,7 +319,7 @@ fn automation_roots(base: &std::path::Path) {
|
||||
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
|
||||
};
|
||||
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
||||
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
|
||||
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
|
||||
fails(&["mcp", "--automation-write-root"], "needs a folder");
|
||||
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
|
||||
@@ -6,29 +6,29 @@ use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
/// The roots the environment gives (`VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`), until
|
||||
/// the command line gives its own.
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect(), read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. CLI values override environment defaults
|
||||
/// once; a second occurrence is an error, including when the two spellings are mixed.
|
||||
/// Returns whether this argument is a root flag. Each flag may be given more than once (#3546),
|
||||
/// in either spelling: the first replaces the environment's root of its kind, the others add
|
||||
/// folders.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
let (roots, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_roots, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_roots, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
@@ -42,8 +42,10 @@ impl AutomationArgs {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
if !std::mem::replace(given, true) {
|
||||
roots.clear();
|
||||
}
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -97,17 +99,24 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_override_environment_once_and_leave_other_arguments() {
|
||||
let mut roots = AutomationArgs::new(Some("environment".into()), None);
|
||||
fn roots_override_environment_and_add_up() {
|
||||
let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
|
||||
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
// #3546: a flag given again adds a folder, in either spelling.
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
|
||||
// A kind the command line leaves alone keeps the environment's root.
|
||||
let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
|
||||
assert!(roots.consume("--automation-read-root=a", &mut std::iter::empty()).unwrap());
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("environment out")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -117,11 +126,15 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
// A bad second folder is an error too, and keeps the first.
|
||||
assert!(roots.consume(&format!("{flag}=first"), &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume(flag, &mut std::iter::empty()).is_err());
|
||||
assert_eq!([roots.read_roots.len(), roots.write_roots.len()].iter().sum::<usize>(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +143,8 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
|
||||
fn control(args: &[&str]) -> Result<Option<u16>, String> {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Linux pen tablets: winit reports barrel buttons as [`winit::event::MouseButton::Other`]
|
||||
//! (for example `BTN_STYLUS`), and egui-winit drops those before they reach the UI (#3632).
|
||||
|
||||
use eframe::AppCreator;
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{DeviceEvent, DeviceId, StartCause, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::window::WindowId;
|
||||
|
||||
use eframe::UserEvent;
|
||||
|
||||
/// `BTN_STYLUS` and neighbours (`linux/input-event-codes.h`): pen barrel buttons Wayland passes
|
||||
/// through the pointer protocol as non-standard mouse buttons.
|
||||
const BTN_STYLUS: u16 = 0x014b;
|
||||
const BTN_STYLUS2: u16 = 0x014c;
|
||||
const BTN_STYLUS3: u16 = 0x014d;
|
||||
|
||||
/// Like [`eframe::run_native`], but turns pen barrel buttons into mouse buttons egui understands.
|
||||
pub fn run_native(app_name: &str, mut native_options: eframe::NativeOptions, app_creator: AppCreator<'_>) -> eframe::Result {
|
||||
let mut builder = EventLoop::<UserEvent>::with_user_event();
|
||||
if let Some(hook) = native_options.event_loop_builder.take() {
|
||||
hook(&mut builder);
|
||||
}
|
||||
let event_loop = builder.build().map_err(eframe::Error::WinitEventLoop)?;
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
let inner = eframe::create_native(app_name, native_options, app_creator, &event_loop);
|
||||
let mut app = Forwarder { inner };
|
||||
event_loop.run_app(&mut app).map_err(eframe::Error::WinitEventLoop)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Forwarder<'a> {
|
||||
inner: eframe::EframeWinitApplication<'a>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler<UserEvent> for Forwarder<'_> {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.inner.resumed(event_loop);
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) {
|
||||
self.inner.window_event(event_loop, window_id, map_window_event(event));
|
||||
}
|
||||
|
||||
fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) {
|
||||
self.inner.new_events(event_loop, cause);
|
||||
}
|
||||
|
||||
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
|
||||
self.inner.user_event(event_loop, event);
|
||||
}
|
||||
|
||||
fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: DeviceId, event: DeviceEvent) {
|
||||
self.inner.device_event(event_loop, device_id, event);
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.inner.about_to_wait(event_loop);
|
||||
}
|
||||
|
||||
fn suspended(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.inner.suspended(event_loop);
|
||||
}
|
||||
|
||||
fn exiting(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.inner.exiting(event_loop);
|
||||
}
|
||||
|
||||
fn memory_warning(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.inner.memory_warning(event_loop);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a tablet button code winit labels `MouseButton::Other` to a standard mouse button.
|
||||
pub(crate) fn map_tablet_button(code: u16) -> Option<winit::event::MouseButton> {
|
||||
use winit::event::MouseButton;
|
||||
match code {
|
||||
BTN_STYLUS | BTN_STYLUS2 => Some(MouseButton::Right),
|
||||
BTN_STYLUS3 => Some(MouseButton::Middle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_window_event(event: WindowEvent) -> WindowEvent {
|
||||
if let WindowEvent::MouseInput { device_id, state, button: winit::event::MouseButton::Other(code), .. } = event
|
||||
&& let Some(button) = map_tablet_button(code)
|
||||
{
|
||||
WindowEvent::MouseInput { device_id, state, button }
|
||||
} else {
|
||||
event
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winit::event::{ElementState, MouseButton};
|
||||
|
||||
#[test]
|
||||
fn stylus_barrel_buttons_become_secondary_clicks() {
|
||||
assert_eq!(map_tablet_button(BTN_STYLUS), Some(MouseButton::Right));
|
||||
assert_eq!(map_tablet_button(BTN_STYLUS2), Some(MouseButton::Right));
|
||||
assert_eq!(map_tablet_button(BTN_STYLUS3), Some(MouseButton::Middle));
|
||||
assert_eq!(map_tablet_button(0x110), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_mouse_input_is_remapped_before_eframe() {
|
||||
let device_id = winit::event::DeviceId::dummy();
|
||||
let press = WindowEvent::MouseInput { device_id, state: ElementState::Pressed, button: MouseButton::Other(BTN_STYLUS) };
|
||||
let mapped = map_window_event(press);
|
||||
assert!(matches!(mapped, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Pressed, .. }));
|
||||
let release = map_window_event(WindowEvent::MouseInput { device_id, state: ElementState::Released, button: MouseButton::Other(BTN_STYLUS2) });
|
||||
assert!(matches!(release, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Released, .. }));
|
||||
}
|
||||
}
|
||||
@@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
|
||||
/// logging never panics and never silently resets to the epoch for a convertible instant.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
|
||||
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
|
||||
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
|
||||
let (y, t) = utc_parts(secs, since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
|
||||
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
|
||||
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -387,6 +395,20 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
|
||||
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
|
||||
// SystemTime can't hold this instant, so it is only checked where it exists.
|
||||
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
|
||||
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//!
|
||||
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
|
||||
@@ -30,6 +30,8 @@ mod control_server;
|
||||
mod cursor_theme;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod gpu;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_stylus;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_fonts;
|
||||
@@ -70,6 +72,12 @@ struct App {
|
||||
frames: u64,
|
||||
/// Keeps the custom title bar's window borderless (Windows, #1758).
|
||||
window_frame: Option<window_frame::WindowFrameFn>,
|
||||
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635).
|
||||
winit_window: Option<std::sync::Arc<winit::window::Window>>,
|
||||
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
|
||||
pending_maximize: bool,
|
||||
/// Frames left to retry [`window::apply_pending_maximize`].
|
||||
maximize_tries: u8,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -147,7 +155,15 @@ impl App {
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
window::track(ctx, &mut self.app.ui.window);
|
||||
if self.pending_maximize {
|
||||
if let Some(w) = &self.winit_window {
|
||||
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
|
||||
} else {
|
||||
self.pending_maximize = false;
|
||||
}
|
||||
}
|
||||
let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
|
||||
window::track(ctx, &mut self.app.ui.window, host_maximized);
|
||||
if self.app.ui.status == "quit" {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
@@ -725,7 +741,9 @@ Options:
|
||||
--control <port> Start the localhost JSON-lines control server on <port>
|
||||
(or VECTORCRAFT_CONTROL_PORT)
|
||||
--automation-read-root <dir> Confine the files control requests read to <dir>
|
||||
(may be given more than once)
|
||||
--automation-write-root <dir> Confine the files control requests write to <dir>
|
||||
(may be given more than once)
|
||||
--in-window-menus Keep the menus inside the window on macOS
|
||||
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
||||
--version Print the version and exit
|
||||
@@ -774,7 +792,7 @@ fn main() -> std::process::ExitCode {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
|
||||
let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
|
||||
// After the arguments: `--control` and `--version` win over a bad environment value.
|
||||
let control_port = match control.port {
|
||||
Some(port) => Some(port),
|
||||
@@ -813,12 +831,14 @@ fn main() -> std::process::ExitCode {
|
||||
// The roots confine the control channel: a bad one keeps the app from starting rather than
|
||||
// leaving agents unconfined. Without a control port there is nothing to confine.
|
||||
let roots = if control_port.is_none() {
|
||||
if read_root.is_some() || write_root.is_some() {
|
||||
if !read_roots.is_empty() || !write_roots.is_empty() {
|
||||
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
|
||||
}
|
||||
None
|
||||
} else {
|
||||
match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) {
|
||||
let read: Vec<&std::path::Path> = read_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let write: Vec<&std::path::Path> = write_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
match AutomationRoots::new(&read, &write) {
|
||||
Ok(roots) => roots,
|
||||
Err(e) => {
|
||||
log::error!("{e}");
|
||||
@@ -934,9 +954,18 @@ fn main() -> std::process::ExitCode {
|
||||
#[cfg(feature = "wgpu")]
|
||||
let created = startup.clone();
|
||||
// A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash.
|
||||
let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_stylus::run_native("VectorCraft", options, app_creator)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
eframe::run_native("VectorCraft", options, app_creator)
|
||||
}
|
||||
};
|
||||
let outcome = vectorcraft_engine::guard::catch_panic(|| {
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
run(
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut session = Session::new();
|
||||
@@ -949,11 +978,15 @@ fn main() -> std::process::ExitCode {
|
||||
// which takes over from the copy the preferences file used to hold.
|
||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
let winit_window = cc.winit_window().cloned();
|
||||
if let Some(w) = &winit_window {
|
||||
let g = window::restore(w, saved_window);
|
||||
app.ui.window = Some(g);
|
||||
#[cfg(windows)]
|
||||
set_taskbar_icon(w);
|
||||
}
|
||||
// Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
|
||||
let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
|
||||
// User Defined swatch and graphic style libraries live next to the preferences.
|
||||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
@@ -1015,7 +1048,7 @@ fn main() -> std::process::ExitCode {
|
||||
// Everywhere the app doesn't say whose work it is (worker threads), the
|
||||
// roots hold: unconfined is only what the person at the keyboard does.
|
||||
file_access::confine_process(roots.clone());
|
||||
log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root());
|
||||
log::info!("control channel confined to the automation roots: {}", roots.describe());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -1034,7 +1067,16 @@ fn main() -> std::process::ExitCode {
|
||||
created.ready();
|
||||
// The content and the pointer stay aligned on Windows (#1758).
|
||||
let window_frame = window_frame::service(cc, custom_titlebar);
|
||||
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame }))
|
||||
Ok(Box::new(App {
|
||||
app,
|
||||
graphics_loss,
|
||||
graphics_lost: false,
|
||||
frames: 0,
|
||||
window_frame,
|
||||
winit_window,
|
||||
pending_maximize,
|
||||
maximize_tries: 8,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -110,7 +110,8 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry.
|
||||
/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
|
||||
/// until the host reports maximized (fixes #3635).
|
||||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||||
let handles: Vec<_> = window.available_monitors().collect();
|
||||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||||
@@ -121,22 +122,41 @@ pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) ->
|
||||
}
|
||||
// Returns the new size when it applies at once; either way the window gets it.
|
||||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
if g.maximized {
|
||||
window.set_maximized(true);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Keep asking the host to maximize until it sticks, or give up after a few frames.
|
||||
pub fn apply_pending_maximize(window: &winit::window::Window, want: bool, tries: &mut u8) -> bool {
|
||||
if !want {
|
||||
return false;
|
||||
}
|
||||
if window.is_maximized() {
|
||||
return false;
|
||||
}
|
||||
if *tries == 0 {
|
||||
return false;
|
||||
}
|
||||
*tries -= 1;
|
||||
window.set_maximized(true);
|
||||
true
|
||||
}
|
||||
|
||||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
||||
/// `host_maximized` is the window manager's idea of maximized when egui does not report it (#3635).
|
||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>, host_maximized: Option<bool>) {
|
||||
let zoom = ctx.zoom_factor();
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||||
return;
|
||||
}
|
||||
if v.maximized == Some(true) {
|
||||
let maximized = v.maximized == Some(true) || host_maximized == Some(true);
|
||||
if maximized {
|
||||
if let Some(g) = geometry {
|
||||
g.maximized = true;
|
||||
}
|
||||
@@ -222,7 +242,7 @@ mod tests {
|
||||
let mut frame = |info: egui::ViewportInfo| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, None)).textures_delta.clear();
|
||||
geometry
|
||||
};
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||||
@@ -253,4 +273,19 @@ mod tests {
|
||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.window, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_maximized_flag_is_remembered_when_egui_does_not_report_it() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut geometry = Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: false });
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1920.0, 1080.0));
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(
|
||||
egui::ViewportId::ROOT,
|
||||
egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: None, ..Default::default() },
|
||||
);
|
||||
let mut out = ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, Some(true)));
|
||||
out.textures_delta.clear();
|
||||
assert_eq!(geometry, Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: true }));
|
||||
}
|
||||
}
|
||||
@@ -60,11 +60,17 @@ fn missing_empty_or_option_operands_fail_before_startup() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3546: a root flag may be given more than once, in either spelling; a bad later operand still
|
||||
/// stops the app before it starts.
|
||||
#[test]
|
||||
fn duplicate_roots_fail_for_both_spellings() {
|
||||
fn repeated_roots_are_accepted_for_both_spellings() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
|
||||
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
|
||||
let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
|
||||
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
|
||||
assert!(out.stderr.is_empty());
|
||||
rejects(&[flag, ".", flag, ""], &format!("{flag} needs a non-empty folder"));
|
||||
rejects(&[&format!("{flag}=."), flag], &format!("{flag} needs a folder"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,3 +154,8 @@ display_name = "Don Omondi"
|
||||
|
||||
[people.alpha-rock]
|
||||
display_name = "Aditya Sisodiya"
|
||||
|
||||
[people.Ronfo2013]
|
||||
real_name = "Angelo Bernardini"
|
||||
display_name = "Angelo Bernardini"
|
||||
sync_github_name = false
|
||||
+50
-18
@@ -11,6 +11,7 @@ Affinity files.
|
||||
| Affinity | VectorCraft |
|
||||
|---|---|
|
||||
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
|
||||
| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
|
||||
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
|
||||
@@ -21,22 +22,24 @@ Affinity files.
|
||||
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
|
||||
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
|
||||
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
|
||||
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
|
||||
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
|
||||
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
|
||||
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them |
|
||||
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) |
|
||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||
active one is used), outlined or scaled text, text fields such as page numbers, frames with
|
||||
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
|
||||
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
|
||||
columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
|
||||
without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
|
||||
|
||||
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
|
||||
with a warning that names the reason; that preview can't be placed, used as a template or mined
|
||||
@@ -78,10 +81,22 @@ of CC0 sample files.
|
||||
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
|
||||
written files in Affinity itself.
|
||||
|
||||
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
|
||||
The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
|
||||
`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
|
||||
overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
|
||||
([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
|
||||
here, from the same public fixtures and the thumbnails of documents their owners made, used only
|
||||
locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
|
||||
was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
|
||||
text-frame fixture.
|
||||
|
||||
## Validation
|
||||
|
||||
* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread,
|
||||
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
|
||||
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
|
||||
`engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail
|
||||
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
|
||||
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
||||
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
||||
mutations.
|
||||
@@ -104,9 +119,14 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
|
||||
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
|
||||
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
|
||||
multiple spreads and resolution conversion, alongside text recovery and native save/export.
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
|
||||
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
|
||||
reader (`container`) and the object stream (`stream`):
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
|
||||
documents with layer effects, colour overlays (also over tables), line styles, paragraph and
|
||||
character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
|
||||
and the embedded stream), and checks that whatever opens also renders and exports.
|
||||
`tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
|
||||
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
|
||||
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
|
||||
stream (`stream`):
|
||||
|
||||
```sh
|
||||
cd crates/affinity
|
||||
@@ -132,11 +152,23 @@ anything is allocated: a value takes about 40 bytes in memory however few it too
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
|
||||
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
|
||||
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
|
||||
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
|
||||
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
|
||||
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
|
||||
text, preserving its characters.
|
||||
Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
|
||||
megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
|
||||
their resolution) and 512 megapixels for the whole document; pixel masks are read only over what
|
||||
they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
|
||||
the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
|
||||
content that many layers link, or a document embedded many times, can't multiply them. Embedded
|
||||
documents nest four deep at most; both of their readings are charged to that budget and to the
|
||||
importing document's archive limits, also when they fail part of the way. Tables have at most
|
||||
4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are
|
||||
limited to 1024 stops. Text collection is bounded to a million characters per node before
|
||||
allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each),
|
||||
features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
|
||||
warns and uses default attributes for remaining text, preserving its characters. Colour and
|
||||
gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
|
||||
each is baked into); past it they are left out, and said. In VectorCraft, master pages are
|
||||
copied onto at most as many objects, characters and pixels as a document may hold, a few times
|
||||
over, and objects beyond `MAX_COORD` of the origin are left out.
|
||||
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
|
||||
@@ -263,6 +263,21 @@ impl<'a> Archive<'a> {
|
||||
}
|
||||
|
||||
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
|
||||
/// What is left of the limits for documents embedded in this one.
|
||||
pub(crate) fn remaining(&self) -> Limits {
|
||||
Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) }
|
||||
}
|
||||
|
||||
/// Count `n` bytes extracted elsewhere (from embedded documents) against this import.
|
||||
pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> {
|
||||
let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?;
|
||||
if total > self.limits.max_total {
|
||||
return Err(Error::Limit("document too large"));
|
||||
}
|
||||
self.extracted = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
|
||||
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
|
||||
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
|
||||
|
||||
@@ -14,6 +14,7 @@ mod access;
|
||||
pub mod container;
|
||||
mod geometry;
|
||||
pub mod model;
|
||||
pub mod overlay;
|
||||
pub mod paint;
|
||||
pub mod preview;
|
||||
mod raster;
|
||||
|
||||
+625
-59
@@ -1,4 +1,4 @@
|
||||
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
|
||||
//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes,
|
||||
//! text and images, with transforms composed into document space. Anything else is reported
|
||||
//! by [`Document::warnings`] instead of being dropped silently.
|
||||
|
||||
@@ -175,17 +175,54 @@ pub struct TextRun {
|
||||
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
|
||||
pub features: Vec<String>,
|
||||
pub all_caps: bool,
|
||||
/// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its
|
||||
/// owner made, all other characters there and in the public corpus have 0).
|
||||
pub underline: bool,
|
||||
/// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck
|
||||
/// through in another document its owner made).
|
||||
pub strikethrough: bool,
|
||||
/// Font size in document pixels, before the node's transform.
|
||||
pub size: f64,
|
||||
/// Tracking in em.
|
||||
pub tracking: f64,
|
||||
/// Horizontal scale of the glyphs (1 = as designed).
|
||||
pub h_scale: f64,
|
||||
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
|
||||
pub leading: Option<f64>,
|
||||
pub fill: Paint,
|
||||
/// Outlined characters: the outline's paint and width (node space).
|
||||
pub stroke: Option<crate::paint::Stroke>,
|
||||
/// The run's paragraph: alignment, indents and spacing.
|
||||
pub paragraph: Paragraph,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the
|
||||
/// node's text scale).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Paragraph {
|
||||
pub align: Align,
|
||||
/// Indent of every line but the first, from the left.
|
||||
pub left: f64,
|
||||
pub right: f64,
|
||||
/// Indent of the first line, from the left.
|
||||
pub first: f64,
|
||||
pub space_before: f64,
|
||||
pub space_after: f64,
|
||||
/// Fixed distance between baselines, when the paragraph sets one.
|
||||
pub leading: Option<f64>,
|
||||
/// Automatic leading as a multiple of the font's own line height (ascent plus descent).
|
||||
pub auto_leading: f64,
|
||||
}
|
||||
|
||||
impl Default for Paragraph {
|
||||
fn default() -> Self {
|
||||
Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
@@ -202,12 +239,69 @@ pub struct Text {
|
||||
pub frame: Option<Rect>,
|
||||
/// Node space to document pixels.
|
||||
pub transform: Affine,
|
||||
/// Node space to document pixels for the type (its linear part matters): artistic text scales
|
||||
/// with its node, frame text only with its frame's text scale.
|
||||
pub text_transform: Affine,
|
||||
}
|
||||
|
||||
/// Vertical alignment of a table cell's text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VAlign {
|
||||
Top,
|
||||
Center,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TableCell {
|
||||
pub runs: Vec<TextRun>,
|
||||
/// Space between the cell edges and its text, in node space: left, top, right, bottom.
|
||||
pub inset: [f64; 4],
|
||||
pub valign: VAlign,
|
||||
/// Part of the cell to its left (merged): it has no text of its own.
|
||||
pub merged_left: bool,
|
||||
}
|
||||
|
||||
/// A table: a grid of cells with text, and a line on every cell edge.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Table {
|
||||
/// Column boundaries (one more than the columns), increasing, in node space.
|
||||
pub columns: Vec<f64>,
|
||||
/// Row boundaries (one more than the rows), increasing, in node space.
|
||||
pub rows: Vec<f64>,
|
||||
/// Row by row.
|
||||
pub cells: Vec<TableCell>,
|
||||
/// Vertical edges row by row, one more per row than the columns; `None` draws no line, and
|
||||
/// the list is empty when the file's didn't match the grid.
|
||||
pub vertical: Vec<Option<Stroke>>,
|
||||
/// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
|
||||
/// as `vertical` can be).
|
||||
pub horizontal: Vec<Option<Stroke>>,
|
||||
/// Node space to document pixels.
|
||||
pub transform: Affine,
|
||||
/// Node space to document pixels for the cells' type: resizing a table moves its grid, not
|
||||
/// its type.
|
||||
pub text_transform: Affine,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
pub fn ncols(&self) -> usize {
|
||||
self.columns.len().saturating_sub(1)
|
||||
}
|
||||
pub fn nrows(&self) -> usize {
|
||||
self.rows.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Pixels {
|
||||
/// Straight-alpha RGBA, 8 bits per channel, row by row.
|
||||
Rgba8(Vec<u8>),
|
||||
/// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
|
||||
/// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
|
||||
/// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
|
||||
/// into a blue cast.
|
||||
Cmyka8(Vec<u8>),
|
||||
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
|
||||
Encoded(Vec<u8>),
|
||||
}
|
||||
@@ -246,11 +340,78 @@ pub enum Kind {
|
||||
even_odd: bool,
|
||||
},
|
||||
Text(Text),
|
||||
Table(Table),
|
||||
Image(Image),
|
||||
/// A master page shown on this spread's pages: pages `page..page + count` show the master
|
||||
/// spread [`Document::masters`]`[master]`'s pages from `master_page` on.
|
||||
MasterInstance {
|
||||
master: usize,
|
||||
page: usize,
|
||||
master_page: usize,
|
||||
count: usize,
|
||||
},
|
||||
/// Something this reader does not import; its children are still imported.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in
|
||||
/// (radians, clockwise from +x as y points down: π/2 puts it below).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Effect {
|
||||
DropShadow {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
radius: f64,
|
||||
distance: f64,
|
||||
angle: f64,
|
||||
},
|
||||
InnerShadow {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
radius: f64,
|
||||
distance: f64,
|
||||
angle: f64,
|
||||
},
|
||||
OuterGlow {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
radius: f64,
|
||||
},
|
||||
/// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred.
|
||||
Outline {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
width: f64,
|
||||
align: u16,
|
||||
},
|
||||
/// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth
|
||||
/// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default);
|
||||
/// `inner` is false for the outer, emboss and pillow kinds.
|
||||
Bevel {
|
||||
size: f64,
|
||||
depth: f64,
|
||||
angle: f64,
|
||||
highlight: crate::paint::Color,
|
||||
highlight_opacity: f64,
|
||||
shadow: crate::paint::Color,
|
||||
shadow_opacity: f64,
|
||||
inner: bool,
|
||||
},
|
||||
/// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at
|
||||
/// `opacity` (the node's own alpha is kept).
|
||||
ColorOverlay {
|
||||
color: crate::paint::Color,
|
||||
opacity: f64,
|
||||
blend: Blend,
|
||||
},
|
||||
/// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels).
|
||||
GradientOverlay {
|
||||
gradient: crate::paint::Gradient,
|
||||
opacity: f64,
|
||||
blend: Blend,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Node {
|
||||
pub class: Tag,
|
||||
@@ -264,6 +425,8 @@ pub struct Node {
|
||||
pub mask: Option<Path>,
|
||||
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
|
||||
pub pixel_mask: Option<Image>,
|
||||
/// Layer effects the importer can show or bake in (others are reported).
|
||||
pub effects: Vec<Effect>,
|
||||
/// Bottom to top.
|
||||
pub children: Vec<Node>,
|
||||
}
|
||||
@@ -285,12 +448,21 @@ pub struct Document {
|
||||
/// Pixels per inch: all geometry is in document pixels at this resolution.
|
||||
pub dpi: f64,
|
||||
pub spreads: Vec<Spread>,
|
||||
/// Master spreads, which [`Kind::MasterInstance`] nodes show on pages.
|
||||
pub masters: Vec<Spread>,
|
||||
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
|
||||
pub saved_by: Option<String>,
|
||||
/// Content that was not imported or was approximated, one line per kind, deduplicated.
|
||||
pub warnings: Vec<String>,
|
||||
/// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`,
|
||||
/// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative
|
||||
/// colorimetric intent and black-point compensation in every document checked.
|
||||
pub cmyk_profile: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
|
||||
const MAX_PROFILE_BYTES: usize = 32 << 20;
|
||||
|
||||
/// Deepest layer nesting followed; deeper content is reported, not imported.
|
||||
const MAX_TREE_DEPTH: usize = 128;
|
||||
/// Total layer nodes imported.
|
||||
@@ -300,6 +472,32 @@ const MAX_NODES: usize = 500_000;
|
||||
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
|
||||
const MAX_LAYER_PIXELS: u64 = 512 << 20;
|
||||
|
||||
/// Embedded documents inside embedded documents are followed this deep.
|
||||
const MAX_EMBED_DEPTH: usize = 4;
|
||||
/// Characters of all stories together (a story may be shared by many text layers).
|
||||
const MAX_DOCUMENT_CHARS: usize = 16 << 20;
|
||||
/// Table cells of all tables together (each becomes a text object with its edge lines).
|
||||
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||
|
||||
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||
/// documents are read with what their embedding document left, so linking or embedding the
|
||||
/// same content many times can't multiply the work.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Budget {
|
||||
/// Layer nodes ([`MAX_NODES`] in all).
|
||||
pub(crate) nodes: usize,
|
||||
/// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||
pub(crate) pixels: u64,
|
||||
/// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
|
||||
pub(crate) chars: usize,
|
||||
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
|
||||
pub(crate) cells: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
|
||||
}
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
read_within(bytes, limits, MAX_LAYER_PIXELS)
|
||||
@@ -307,35 +505,81 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
|
||||
/// [`read`], its pixel layers holding at most `pixels` pixels together.
|
||||
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
|
||||
}
|
||||
|
||||
/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
|
||||
/// either way.
|
||||
struct ReadAt {
|
||||
document: Result<Document, Error>,
|
||||
extracted: usize,
|
||||
}
|
||||
|
||||
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
|
||||
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
|
||||
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
|
||||
let mut archive = match Archive::open(bytes, limits) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return ReadAt { document: Err(e), extracted: 0 },
|
||||
};
|
||||
let document = read_archive(&mut archive, budget, base, depth);
|
||||
ReadAt { document, extracted: archive.extracted() }
|
||||
}
|
||||
|
||||
fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result<Document, Error> {
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
let mut reader = Reader::new(&s, &mut archive);
|
||||
reader.pixels_left = pixels;
|
||||
reader.document()
|
||||
let mut reader = Reader::new(&s, archive, base, depth);
|
||||
reader.budget = *budget;
|
||||
let document = reader.document();
|
||||
*budget = reader.budget;
|
||||
document
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
pub(crate) s: &'s Stream,
|
||||
pub(crate) archive: &'b mut Archive<'a>,
|
||||
pub(crate) dpi: f64,
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
pub(crate) warnings: BTreeMap<String, usize>,
|
||||
active: HashSet<ObjId>,
|
||||
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||
pub(crate) pixels_left: u64,
|
||||
/// Where the spreads go: the identity, or an embedding document's placement.
|
||||
base: Affine,
|
||||
/// The linear part of `base`, which also scales frame text.
|
||||
pub(crate) type_base: Affine,
|
||||
/// How deep inside embedded documents this one is.
|
||||
depth: usize,
|
||||
/// An embedded document's content, waiting to become its node's children.
|
||||
pending: Option<Vec<Node>>,
|
||||
/// The master spreads, in the order of [`Document::masters`].
|
||||
master_ids: Vec<ObjId>,
|
||||
/// What the rest of the read may use.
|
||||
pub(crate) budget: Budget,
|
||||
}
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
|
||||
pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
|
||||
let [a, b, c, d, ..] = base.0;
|
||||
Self {
|
||||
s,
|
||||
archive,
|
||||
dpi: 72.0,
|
||||
warnings: BTreeMap::new(),
|
||||
active: HashSet::new(),
|
||||
base,
|
||||
type_base: Affine([a, b, c, d, 0.0, 0.0]),
|
||||
depth,
|
||||
pending: None,
|
||||
master_ids: Vec::new(),
|
||||
budget: Budget::FULL,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
*self.warnings.entry(what.into()).or_default() += 1;
|
||||
}
|
||||
|
||||
fn document(mut self) -> Result<Document, Error> {
|
||||
/// The document.
|
||||
fn document(&mut self) -> Result<Document, Error> {
|
||||
let s = self.s;
|
||||
let root = s.root;
|
||||
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
|
||||
@@ -347,40 +591,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
}
|
||||
});
|
||||
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
|
||||
self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect();
|
||||
let mut spreads = Vec::new();
|
||||
for spread in s.objs(doc, b"Chld") {
|
||||
if !s.is(spread, b"Sprd") {
|
||||
self.warn("an unknown kind of page");
|
||||
continue;
|
||||
if let Some(sp) = self.spread(doc, spread)? {
|
||||
spreads.push(sp);
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
let default_size =
|
||||
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
y0: 0.0,
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
});
|
||||
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
|
||||
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
|
||||
if mask.is_some() {
|
||||
self.warn("mask layers directly on a page (imported without them)");
|
||||
}
|
||||
spreads.push(Spread { bounds, pages, transparent, nodes });
|
||||
}
|
||||
if spreads.is_empty() {
|
||||
return Err(Error::Malformed("document has no pages"));
|
||||
}
|
||||
if !s.objs(doc, b"MpCh").is_empty() {
|
||||
self.warn("master pages (their content is not placed on the pages that use them)");
|
||||
let mut masters = Vec::new();
|
||||
for master in self.master_ids.clone() {
|
||||
match self.spread(doc, master)? {
|
||||
Some(sp) => masters.push(sp),
|
||||
// Keep the indices of `MasterInstance::master` valid.
|
||||
None => masters.push(Spread {
|
||||
bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 },
|
||||
pages: Vec::new(),
|
||||
transparent: true,
|
||||
nodes: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
|
||||
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
|
||||
let cmyk_profile = s
|
||||
.obj(root, b"CMYP")
|
||||
.and_then(|p| s.bytes(p, b"Data"))
|
||||
.filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
|
||||
.map(<[u8]>::to_vec);
|
||||
let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
|
||||
Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
|
||||
}
|
||||
|
||||
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
|
||||
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
|
||||
/// A spread (`Sprd`) of the document `doc`, or of its master spreads.
|
||||
fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result<Option<Spread>, Error> {
|
||||
let s = self.s;
|
||||
if !s.is(spread, b"Sprd") {
|
||||
self.warn("an unknown kind of page");
|
||||
return Ok(None);
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
y0: 0.0,
|
||||
x1: 0.0,
|
||||
y1: 0.0,
|
||||
});
|
||||
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
|
||||
let (nodes, mask) = self.children(spread, self.base, 0)?;
|
||||
if mask.is_some() {
|
||||
self.warn("mask layers directly on a page (imported without them)");
|
||||
}
|
||||
Ok(Some(Spread { bounds, pages, transparent, nodes }))
|
||||
}
|
||||
|
||||
/// The children of `parent`, and the mask layer among them that masks the parent (with its
|
||||
/// placement), read later over just what it masks.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<(ObjId, Affine)>), Error> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
let mut mask = None;
|
||||
@@ -391,10 +660,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
continue;
|
||||
}
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
match crate::raster::mask(self, c, local.then(world))? {
|
||||
Some(m) if mask.is_none() => mask = Some(m),
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
if mask.is_none() {
|
||||
mask = Some((c, local.then(world)));
|
||||
} else {
|
||||
self.warn("several pixel masks on one layer (only the first is used)");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -411,10 +680,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
self.warn("layers nested deeper than 128 levels");
|
||||
return Ok(None);
|
||||
}
|
||||
self.nodes += 1;
|
||||
if self.nodes > MAX_NODES {
|
||||
let Some(left) = self.budget.nodes.checked_sub(1) else {
|
||||
return Err(Error::Limit("too many layers"));
|
||||
}
|
||||
};
|
||||
self.budget.nodes = left;
|
||||
// A node that contains itself (a shared object linked back into its own subtree).
|
||||
if !self.active.insert(id) {
|
||||
self.warn("a layer that contains itself");
|
||||
@@ -441,15 +710,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
} else {
|
||||
self.kind(id, class, world, parent)?
|
||||
};
|
||||
// This node's embedded content, before its children's `node` calls can take it.
|
||||
let embedded = self.pending.take();
|
||||
// A compound's children are its operands, already merged into its outline.
|
||||
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
|
||||
let (mask, attached_mask) = self.attached_masks(id, world)?;
|
||||
let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
|
||||
if let Some(embedded) = embedded {
|
||||
children.extend(embedded);
|
||||
}
|
||||
// Pixel masks are read only over what they mask (they can span a whole spread at print
|
||||
// resolution, a gigapixel).
|
||||
let over = mask_extent(&kind, &children);
|
||||
let child_mask = match child_mask {
|
||||
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
|
||||
None => None,
|
||||
};
|
||||
let (mask, attached_mask) = self.attached_masks(id, world, over)?;
|
||||
let pixel_mask = attached_mask.or(child_mask);
|
||||
self.active.remove(&id);
|
||||
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
|
||||
self.warn("layer effects (shadows, glows, outlines, bevels…)");
|
||||
}
|
||||
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
|
||||
let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
|
||||
// Gradient overlays are laid out over the node's bounds, also while it is hidden.
|
||||
let bounds = tree_bounds(&node);
|
||||
node.effects = self.effects(id, world, bounds);
|
||||
Ok(Some(node))
|
||||
}
|
||||
|
||||
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
|
||||
@@ -471,6 +753,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let (fills, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
||||
}
|
||||
b"TxtT" => match crate::text::table(self, id, world) {
|
||||
Some(t) => Kind::Table(t),
|
||||
None => {
|
||||
self.warn("tables that could not be read");
|
||||
Kind::Unsupported
|
||||
}
|
||||
},
|
||||
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
|
||||
Some(t) => Kind::Text(t),
|
||||
None => {
|
||||
@@ -482,15 +771,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Some(img) => Kind::Image(img),
|
||||
None => Kind::Unsupported,
|
||||
},
|
||||
b"EmbN" => match crate::raster::embedded(self, id, world)? {
|
||||
Some(img) => {
|
||||
self.warn("embedded documents and symbols (imported as pictures of them)");
|
||||
Kind::Image(img)
|
||||
b"MPIN" => {
|
||||
// `SLnk` links the instance with the master spread it shows.
|
||||
let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default();
|
||||
let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o));
|
||||
let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096);
|
||||
match master {
|
||||
Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) },
|
||||
None => {
|
||||
self.warn("master page instances without their master");
|
||||
Kind::Unsupported
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.warn("embedded documents and symbols without a cached picture");
|
||||
Kind::Unsupported
|
||||
}
|
||||
b"EmbN" => match self.embedded_document(id, world) {
|
||||
// The embedded document itself, as editable content.
|
||||
Some(nodes) => {
|
||||
self.pending = Some(nodes);
|
||||
Kind::Group
|
||||
}
|
||||
None => match crate::raster::embedded(self, id, world)? {
|
||||
Some(img) => {
|
||||
self.warn("embedded documents and symbols (imported as pictures of them)");
|
||||
Kind::Image(img)
|
||||
}
|
||||
None => {
|
||||
self.warn("embedded documents and symbols without a cached picture");
|
||||
Kind::Unsupported
|
||||
}
|
||||
},
|
||||
},
|
||||
b"FRst" => {
|
||||
// A fill layer paints its whole bitmap area; its gradient lives in page space.
|
||||
@@ -515,9 +824,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the
|
||||
/// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below.
|
||||
fn effects(&mut self, id: ObjId, world: Affine, bounds: Option<Rect>) -> Vec<Effect> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
for e in s.objs(id, b"FiEf").into_iter().take(64) {
|
||||
if s.bool(e, b"Enab") == Some(false) {
|
||||
continue;
|
||||
}
|
||||
// "Scale with object": lengths follow the node's transform; otherwise they are absolute.
|
||||
let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 };
|
||||
let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k;
|
||||
let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c));
|
||||
let class = s.class(e).map(|c| c.0.to_be_bytes());
|
||||
match (class.as_ref(), color) {
|
||||
(Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
|
||||
(Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
|
||||
(Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }),
|
||||
(Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => {
|
||||
let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a);
|
||||
out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align });
|
||||
}
|
||||
(Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"),
|
||||
(Some(b"BevE" | b"EmbE"), _) => {
|
||||
// No radius, no bevel (as Affinity draws it).
|
||||
let size = len(b"Radi");
|
||||
if size > 0.0 {
|
||||
let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
|
||||
let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
|
||||
let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white);
|
||||
let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black);
|
||||
let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0);
|
||||
let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0);
|
||||
let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0);
|
||||
let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0);
|
||||
out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner });
|
||||
}
|
||||
}
|
||||
(Some(b"ColO"), Some(color)) => {
|
||||
let blend = self.effect_blend(e);
|
||||
out.push(Effect::ColorOverlay { color, opacity, blend });
|
||||
}
|
||||
(Some(b"GrdO"), _) => {
|
||||
let blend = self.effect_blend(e);
|
||||
// The gradient's unit space spans the node's bounding box from -1 to 1, centred
|
||||
// on it (y down): checked against the stored previews of real documents.
|
||||
let space =
|
||||
bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]));
|
||||
match (s.obj(e, b"GrFl"), space) {
|
||||
(Some(fill), Some(space)) => match paint::descriptor(self, fill, space) {
|
||||
Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }),
|
||||
Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }),
|
||||
_ => {}
|
||||
},
|
||||
// Nothing visible to lay it over.
|
||||
(Some(_), None) => {}
|
||||
(None, _) => self.warn("gradient overlay effects that could not be read (left out)"),
|
||||
}
|
||||
}
|
||||
(Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"),
|
||||
(Some(b"Gaus"), _) => self.warn("blur effects (left out)"),
|
||||
_ => self.warn("layer effects of other kinds (left out)"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
|
||||
fn effect_blend(&mut self, e: ObjId) -> Blend {
|
||||
match self.s.enumeration(e, b"BlnM") {
|
||||
None => Blend::Normal,
|
||||
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
|
||||
self.warn("an unknown blend mode (imported as Normal)");
|
||||
Blend::Normal
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
|
||||
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
|
||||
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
|
||||
fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option<Rect>) -> Result<(Option<Path>, Option<Image>), Error> {
|
||||
let s = self.s;
|
||||
let attached = s.objs(id, b"AdCh");
|
||||
let mut mask: Option<Path> = None;
|
||||
@@ -534,7 +923,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
}
|
||||
} else if class == Tag::of(b"MRst") {
|
||||
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
|
||||
match crate::raster::mask(self, a, local)? {
|
||||
match crate::raster::mask(self, a, local, over)? {
|
||||
Some(m) if pixels.is_none() => pixels = Some(m),
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
@@ -546,6 +935,93 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Ok((mask, pixels))
|
||||
}
|
||||
|
||||
/// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
|
||||
/// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
|
||||
/// placed so the centre of its page, first artboard or content is the node's origin.
|
||||
fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option<Vec<Node>> {
|
||||
let s = self.s;
|
||||
let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
|
||||
if entry.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if self.depth >= MAX_EMBED_DEPTH {
|
||||
self.warn("embedded documents nested more than four deep");
|
||||
return None;
|
||||
}
|
||||
let data = self.archive.read(&entry).ok()?;
|
||||
let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
|
||||
if !crate::is_affinity(inner) {
|
||||
return None;
|
||||
}
|
||||
// A first reading finds its page and resolution, the second places it.
|
||||
// Both readings are charged to the budget and the archive's limits, also when they fail:
|
||||
// each does the work.
|
||||
let mut budget = self.budget;
|
||||
let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
|
||||
self.budget = budget;
|
||||
if self.archive.charge(probe.extracted).is_err() {
|
||||
self.warn("embedded documents beyond the size limit (left out)");
|
||||
return None;
|
||||
}
|
||||
let probe = match probe.document {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.warn(format!("embedded documents that could not be read ({e})"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let spread = probe.spreads.first()?;
|
||||
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
|
||||
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
|
||||
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
|
||||
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
|
||||
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
|
||||
// Its pixels aren't needed any more: never hold both readings' at once.
|
||||
drop(probe);
|
||||
// Its document pixels are the node's units, whatever either document's resolution.
|
||||
let k = 1.0;
|
||||
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
|
||||
let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
|
||||
if !base.is_finite() {
|
||||
self.warn("embedded documents with an invalid placement");
|
||||
return None;
|
||||
}
|
||||
let mut budget = self.budget;
|
||||
let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
|
||||
self.budget = budget;
|
||||
if self.archive.charge(placed.extracted).is_err() {
|
||||
self.warn("embedded documents beyond the size limit (left out)");
|
||||
return None;
|
||||
}
|
||||
let doc = placed.document.ok()?;
|
||||
for w in doc.warnings {
|
||||
self.warn(format!("{w} [in embedded documents]"));
|
||||
}
|
||||
if doc.spreads.len() > 1 {
|
||||
self.warn("embedded documents with several pages (the first is used)");
|
||||
}
|
||||
let mut nodes = doc.spreads.into_iter().next()?.nodes;
|
||||
// Its master page instances count its own masters, which aren't kept: in this document
|
||||
// they would show other masters.
|
||||
fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
|
||||
let mut found = false;
|
||||
for n in nodes {
|
||||
if matches!(n.kind, Kind::MasterInstance { .. }) {
|
||||
n.kind = Kind::Unsupported;
|
||||
found = true;
|
||||
}
|
||||
if depth < MAX_TREE_DEPTH {
|
||||
found |= strip_masters(&mut n.children, depth + 1);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
if strip_masters(&mut nodes, 0) {
|
||||
self.warn("master pages of embedded documents (left out)");
|
||||
}
|
||||
Some(nodes)
|
||||
}
|
||||
|
||||
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
|
||||
let s = self.s;
|
||||
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
|
||||
@@ -580,6 +1056,96 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
|
||||
/// and tables), in document pixels.
|
||||
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
|
||||
let mut out = None;
|
||||
walk_bounds(nodes, true, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The bounds of `node` and everything in it, hidden or not.
|
||||
fn tree_bounds(node: &Node) -> Option<Rect> {
|
||||
let mut out = None;
|
||||
walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option<Rect>) {
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in nodes.iter().filter(|n| n.visible || !visible_only) {
|
||||
kind_points(&n.kind, &mut |p| add_point(out, p));
|
||||
walk_bounds(&n.children, visible_only, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
|
||||
/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
|
||||
/// artistic text, whose glyphs only the layout places.
|
||||
fn mask_extent(kind: &Kind, children: &[Node]) -> Option<Rect> {
|
||||
fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option<Rect>) -> bool {
|
||||
if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
|
||||
return false;
|
||||
}
|
||||
let mut own = kind_bounds(kind);
|
||||
if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
|
||||
// As far as a stroke can reach out of its path, miter joins included.
|
||||
let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
|
||||
own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
|
||||
}
|
||||
if let Some(r) = own {
|
||||
*out = Some(out.map_or(r, |o| union(o, r)));
|
||||
}
|
||||
children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
|
||||
}
|
||||
let mut out = None;
|
||||
if visit(kind, children, 0, &mut out) { out } else { None }
|
||||
}
|
||||
|
||||
/// The bounds of what one node's own kind draws (not its children): its outline's points, its
|
||||
/// image's or frame's corners, or artistic text's anchor.
|
||||
pub fn kind_bounds(kind: &Kind) -> Option<Rect> {
|
||||
let mut out = None;
|
||||
kind_points(kind, &mut |p| add_point(&mut out, p));
|
||||
out
|
||||
}
|
||||
|
||||
fn add_point(out: &mut Option<Rect>, p: Point) {
|
||||
if p.x.is_finite() && p.y.is_finite() {
|
||||
let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
|
||||
*out = Some(out.map_or(r, |o| union(o, r)));
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
|
||||
let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
|
||||
match kind {
|
||||
Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
|
||||
for sp in &path.subpaths {
|
||||
add(sp.start);
|
||||
for seg in &sp.segments {
|
||||
seg.iter().for_each(|p| add(*p));
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Image(img) => {
|
||||
corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
|
||||
}
|
||||
Kind::Text(t) => match t.frame {
|
||||
Some(f) => corners(f, t.transform).into_iter().for_each(add),
|
||||
None => add(t.transform.apply(t.anchor)),
|
||||
},
|
||||
Kind::Table(t) => {
|
||||
if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
|
||||
corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
|
||||
}
|
||||
}
|
||||
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
|
||||
/// equal pages (Publisher 1 facing spreads).
|
||||
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
|
||||
//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
|
||||
//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
|
||||
//!
|
||||
//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
|
||||
//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
|
||||
//! Darker/Lighter Color) use their usual definitions.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
|
||||
use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
|
||||
|
||||
/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
|
||||
const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Most pixels an overlay is baked into per image.
|
||||
const MAX_PIXELS: usize = 64 << 20;
|
||||
|
||||
/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
|
||||
/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
|
||||
/// and shared content would otherwise multiply it; past it, overlays are left out and said.
|
||||
pub const MAX_WORK: u64 = 1 << 29;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Source<'a> {
|
||||
Solid(Color),
|
||||
Gradient(&'a Gradient),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Overlay<'a> {
|
||||
source: Source<'a>,
|
||||
opacity: f64,
|
||||
blend: Blend,
|
||||
}
|
||||
|
||||
/// `node` with its colour and gradient overlays baked in, and those of everything in it
|
||||
/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
|
||||
/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
|
||||
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
|
||||
if overlays(&node.effects).is_empty() {
|
||||
return Cow::Borrowed(node);
|
||||
}
|
||||
let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
|
||||
warn("overlay effects beyond the work one import may take (left out)");
|
||||
return Cow::Borrowed(node);
|
||||
};
|
||||
*work = left;
|
||||
let mut out = node.clone();
|
||||
bake_tree(&mut out, work, 0, warn);
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The visible colour and gradient overlays among `effects`.
|
||||
fn overlays(effects: &[Effect]) -> Vec<Overlay<'_>> {
|
||||
effects
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
|
||||
Effect::GradientOverlay { gradient, opacity, blend } => {
|
||||
Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.filter(|o| o.opacity > 0.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_overlay(e: &Effect) -> bool {
|
||||
matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
|
||||
}
|
||||
|
||||
/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
|
||||
/// lies over what its node shows, its children's overlays included. Each overlay pays for the
|
||||
/// subtree it is baked into out of `work`; past it, it is left out (and said).
|
||||
fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in &mut node.children {
|
||||
bake_tree(c, work, depth + 1, warn);
|
||||
}
|
||||
if !node.effects.iter().any(is_overlay) {
|
||||
return;
|
||||
}
|
||||
let (effects, rest): (Vec<Effect>, Vec<Effect>) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
|
||||
node.effects = rest;
|
||||
let overlays = overlays(&effects);
|
||||
if overlays.is_empty() {
|
||||
return;
|
||||
}
|
||||
let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
|
||||
let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
|
||||
let Some(left) = work.checked_sub(cost) else {
|
||||
warn("overlay effects beyond the work one import may take (left out)");
|
||||
return;
|
||||
};
|
||||
*work = left;
|
||||
for o in &overlays {
|
||||
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
|
||||
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
|
||||
}
|
||||
bake_node(node, o, 0, warn);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
|
||||
fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
|
||||
if depth > MAX_DEPTH {
|
||||
return 0;
|
||||
}
|
||||
let mut cost: u64 = match &node.kind {
|
||||
Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
|
||||
Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
|
||||
Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
|
||||
_ => 1,
|
||||
};
|
||||
for c in &node.children {
|
||||
if cost > limit {
|
||||
break;
|
||||
}
|
||||
cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match &mut node.kind {
|
||||
Kind::Shape { fills, strokes, .. } => {
|
||||
for f in fills.iter_mut() {
|
||||
*f = paint(f, o, warn);
|
||||
}
|
||||
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
|
||||
}
|
||||
Kind::Artboard { background, strokes, .. } => {
|
||||
for f in background.iter_mut() {
|
||||
*f = paint(f, o, warn);
|
||||
}
|
||||
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
|
||||
}
|
||||
Kind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
r.fill = paint(&r.fill, o, warn);
|
||||
if let Some(s) = &mut r.stroke {
|
||||
stroke(s, o, warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
Kind::Table(t) => {
|
||||
for c in &mut t.cells {
|
||||
for r in &mut c.runs {
|
||||
r.fill = paint(&r.fill, o, warn);
|
||||
if let Some(s) = &mut r.stroke {
|
||||
stroke(s, o, warn);
|
||||
}
|
||||
}
|
||||
}
|
||||
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
|
||||
stroke(s, o, warn);
|
||||
}
|
||||
}
|
||||
Kind::Image(img) => {
|
||||
if !pixels(img, o) {
|
||||
warn("overlay effects on images too large to recolour (left out)");
|
||||
}
|
||||
}
|
||||
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
|
||||
}
|
||||
for e in &mut node.effects {
|
||||
match e {
|
||||
Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
|
||||
Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
|
||||
Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
|
||||
}
|
||||
}
|
||||
for c in &mut node.children {
|
||||
bake_node(c, o, depth + 1, warn);
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
|
||||
s.paint = paint(&s.paint, o, warn);
|
||||
}
|
||||
|
||||
/// A paint with the overlay over it.
|
||||
fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
|
||||
match (p, o.source) {
|
||||
(Paint::None, _) => Paint::None,
|
||||
(Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
|
||||
(Paint::Gradient(g), Source::Solid(s)) => {
|
||||
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
|
||||
}
|
||||
// Over one colour, the result is the overlay's gradient with each stop laid over it.
|
||||
(Paint::Solid(c), Source::Gradient(g)) => {
|
||||
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
|
||||
}
|
||||
(Paint::Gradient(base), Source::Gradient(g)) => {
|
||||
if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
|
||||
warn("gradient overlays on gradients (approximated)");
|
||||
}
|
||||
// Exact when the overlay covers it; otherwise over the base gradient's average colour.
|
||||
let avg = average(base);
|
||||
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Source<'_> {
|
||||
/// One colour for the source (a gradient's average) where only one fits.
|
||||
fn at_solid(&self) -> Color {
|
||||
match self {
|
||||
Source::Solid(c) => *c,
|
||||
Source::Gradient(g) => average(g),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn average(g: &Gradient) -> Color {
|
||||
let n = g.stops.len().max(1) as f64;
|
||||
let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
|
||||
for s in &g.stops {
|
||||
let [sr, sg, sb] = rgb(s.color);
|
||||
r += sr;
|
||||
gr += sg;
|
||||
b += sb;
|
||||
a += s.color.alpha();
|
||||
}
|
||||
Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
|
||||
}
|
||||
|
||||
/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
|
||||
fn over(base: Color, source: Color, o: &Overlay) -> Color {
|
||||
let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
|
||||
let alpha = base.alpha();
|
||||
// Normal mixes within the colour model when both share it, so CMYK stays CMYK.
|
||||
if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
|
||||
let mix = |a: f64, b: f64| a + (b - a) * k;
|
||||
match (base, source) {
|
||||
(Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
|
||||
return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
|
||||
}
|
||||
(Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
|
||||
(_, s) if k >= 1.0 => return with_alpha(s, alpha),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let b = rgb(base);
|
||||
let s = rgb(source);
|
||||
let blended = blend(o.blend, b, s);
|
||||
let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
|
||||
Color::Rgb { r, g, b: bl, a: alpha }
|
||||
}
|
||||
|
||||
fn with_alpha(c: Color, a: f64) -> Color {
|
||||
match c {
|
||||
Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
|
||||
Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
|
||||
Color::Gray { v, .. } => Color::Gray { v, a },
|
||||
Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
|
||||
/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
|
||||
fn rgb(c: Color) -> [f64; 3] {
|
||||
let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
match c {
|
||||
Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
|
||||
Color::Gray { v, .. } => [f(v); 3],
|
||||
Color::Cmyk { c, m, y, k, .. } => {
|
||||
let w = 1.0 - f(k);
|
||||
[(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
|
||||
}
|
||||
Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
|
||||
}
|
||||
}
|
||||
|
||||
/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
|
||||
fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
|
||||
let (l, a, b) = (finite(l), finite(a), finite(b));
|
||||
let fy = (l + 16.0) / 116.0;
|
||||
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
|
||||
let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
|
||||
// D50 white.
|
||||
let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
|
||||
// XYZ (D50) to linear sRGB, Bradford-adapted.
|
||||
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
|
||||
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
|
||||
let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
|
||||
let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
|
||||
[gamma(r), gamma(g), gamma(bl)]
|
||||
}
|
||||
|
||||
/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
|
||||
fn pixels(img: &mut Image, o: &Overlay) -> bool {
|
||||
let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
|
||||
let Some(len) = n.checked_mul(4) else { return false };
|
||||
let mut px = match &img.pixels {
|
||||
Pixels::Rgba8(p) => match p.get(..len) {
|
||||
Some(p) => p.to_vec(),
|
||||
None => return false,
|
||||
},
|
||||
Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
},
|
||||
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
|
||||
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
|
||||
Some(p) if p.len() == n * 5 => p
|
||||
.as_chunks::<5>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|[c, m, y, k, a]| {
|
||||
let w = 255 - u16::from(*k);
|
||||
let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
|
||||
[ch(*c), ch(*m), ch(*y), *a]
|
||||
})
|
||||
.collect(),
|
||||
_ => return false,
|
||||
},
|
||||
};
|
||||
let width = img.width.max(1) as usize;
|
||||
// A solid source is converted once; a gradient is sampled at each pixel's centre.
|
||||
let solid = match o.source {
|
||||
Source::Solid(c) => Some((rgb(c), c.alpha())),
|
||||
Source::Gradient(_) => None,
|
||||
};
|
||||
let to_unit = match o.source {
|
||||
Source::Gradient(g) => match invert(g.transform) {
|
||||
Some(m) => Some((img.transform.then(m), g)),
|
||||
None => return false,
|
||||
},
|
||||
Source::Solid(_) => None,
|
||||
};
|
||||
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let (s, sa) = match (solid, &to_unit) {
|
||||
(Some(s), _) => s,
|
||||
(None, Some((m, g))) => {
|
||||
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
|
||||
let c = sample(g, q);
|
||||
(rgb(c), c.alpha())
|
||||
}
|
||||
(None, None) => continue,
|
||||
};
|
||||
let k = (o.opacity * sa).clamp(0.0, 1.0);
|
||||
let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
|
||||
let m = blend(o.blend, b, s);
|
||||
for c in 0..3 {
|
||||
p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
img.pixels = Pixels::Rgba8(px);
|
||||
true
|
||||
}
|
||||
|
||||
/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
|
||||
fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
if probe.into_dimensions().ok()? != (width, height) {
|
||||
return None;
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(width);
|
||||
limits.max_image_height = Some(height);
|
||||
limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
|
||||
reader.limits(limits);
|
||||
Some(reader.decode().ok()?.into_rgba8().into_raw())
|
||||
}
|
||||
|
||||
/// The gradient's colour at unit-space point `q`.
|
||||
fn sample(g: &Gradient, q: Point) -> Color {
|
||||
let t = match g.kind {
|
||||
GradientKind::Linear => q.x,
|
||||
GradientKind::Radial => q.x.hypot(q.y),
|
||||
GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
|
||||
};
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } };
|
||||
if t <= first.offset {
|
||||
return first.color;
|
||||
}
|
||||
for w in g.stops.windows(2) {
|
||||
let [a, b] = [&w[0], &w[1]];
|
||||
if t <= b.offset {
|
||||
let span = b.offset - a.offset;
|
||||
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
|
||||
// Affinity's midpoint bias (see `Stop::half_point`).
|
||||
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
|
||||
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
|
||||
let (x, y) = (rgb(a.color), rgb(b.color));
|
||||
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
|
||||
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
|
||||
}
|
||||
}
|
||||
last.color
|
||||
}
|
||||
|
||||
fn invert(m: Affine) -> Option<Affine> {
|
||||
let [a, b, c, d, e, f] = m.0;
|
||||
let det = a * d - b * c;
|
||||
if !(det.is_finite() && det.abs() > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
|
||||
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
|
||||
}
|
||||
|
||||
/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
|
||||
fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
|
||||
use Blend::*;
|
||||
let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
|
||||
match mode {
|
||||
Normal | PassThrough | Erase => s,
|
||||
Darken => sep(f64::min),
|
||||
Lighten => sep(f64::max),
|
||||
Multiply => sep(|b, s| b * s),
|
||||
Screen => sep(screen),
|
||||
ColorBurn => sep(burn),
|
||||
ColorDodge => sep(dodge),
|
||||
Blend::Overlay => sep(|b, s| hard_light(s, b)),
|
||||
HardLight => sep(hard_light),
|
||||
SoftLight => sep(soft_light),
|
||||
Difference => sep(|b, s| (b - s).abs()),
|
||||
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
|
||||
Add => sep(|b, s| b + s),
|
||||
Subtract => sep(|b, s| b - s),
|
||||
VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
|
||||
LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
|
||||
PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
|
||||
HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
|
||||
Average => sep(|b, s| (b + s) / 2.0),
|
||||
Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
|
||||
Reflect => sep(reflect),
|
||||
Glow => sep(|b, s| reflect(s, b)),
|
||||
DarkerColor => {
|
||||
if lum(s) < lum(b) {
|
||||
s
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
LighterColor => {
|
||||
if lum(s) > lum(b) {
|
||||
s
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
Hue => set_lum(set_sat(s, sat(b)), lum(b)),
|
||||
Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
|
||||
Blend::Color => set_lum(s, lum(b)),
|
||||
Luminosity => set_lum(b, lum(s)),
|
||||
}
|
||||
}
|
||||
|
||||
fn screen(b: f64, s: f64) -> f64 {
|
||||
b + s - b * s
|
||||
}
|
||||
|
||||
fn burn(b: f64, s: f64) -> f64 {
|
||||
if b >= 1.0 {
|
||||
1.0
|
||||
} else if s <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - ((1.0 - b) / s).min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn dodge(b: f64, s: f64) -> f64 {
|
||||
if b <= 0.0 {
|
||||
0.0
|
||||
} else if s >= 1.0 {
|
||||
1.0
|
||||
} else {
|
||||
(b / (1.0 - s)).min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn hard_light(b: f64, s: f64) -> f64 {
|
||||
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
|
||||
}
|
||||
|
||||
fn soft_light(b: f64, s: f64) -> f64 {
|
||||
if s <= 0.5 {
|
||||
b - (1.0 - 2.0 * s) * b * (1.0 - b)
|
||||
} else {
|
||||
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||||
b + (2.0 * s - 1.0) * (d - b)
|
||||
}
|
||||
}
|
||||
|
||||
fn reflect(b: f64, s: f64) -> f64 {
|
||||
if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
|
||||
}
|
||||
|
||||
fn lum(c: [f64; 3]) -> f64 {
|
||||
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
|
||||
}
|
||||
|
||||
fn clip_color(c: [f64; 3]) -> [f64; 3] {
|
||||
let l = lum(c);
|
||||
let n = c[0].min(c[1]).min(c[2]);
|
||||
let x = c[0].max(c[1]).max(c[2]);
|
||||
c.map(|v| {
|
||||
let mut v = v;
|
||||
if n < 0.0 && l - n > 1e-12 {
|
||||
v = l + (v - l) * l / (l - n);
|
||||
}
|
||||
if x > 1.0 && x - l > 1e-12 {
|
||||
v = l + (v - l) * (1.0 - l) / (x - l);
|
||||
}
|
||||
v.clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
|
||||
let d = l - lum(c);
|
||||
clip_color(c.map(|v| v + d))
|
||||
}
|
||||
|
||||
fn sat(c: [f64; 3]) -> f64 {
|
||||
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
|
||||
}
|
||||
|
||||
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
|
||||
let max = c[0].max(c[1]).max(c[2]);
|
||||
let min = c[0].min(c[1]).min(c[2]);
|
||||
if max - min <= 1e-12 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
c.map(|v| (v - min) * s / (max - min))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
|
||||
Node {
|
||||
class: crate::stream::Tag::of(b"ShpN"),
|
||||
name: String::new(),
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1.0,
|
||||
blend: Blend::Normal,
|
||||
kind: Kind::Shape {
|
||||
path: Default::default(),
|
||||
fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
|
||||
strokes: vec![],
|
||||
even_odd: false,
|
||||
},
|
||||
mask: None,
|
||||
pixel_mask: None,
|
||||
effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
|
||||
children: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(n: &Node) -> Color {
|
||||
match &n.kind {
|
||||
Kind::Shape { fills, .. } => match fills.first() {
|
||||
Some(Paint::Solid(c)) => *c,
|
||||
other => panic!("{other:?}"),
|
||||
},
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
|
||||
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
|
||||
let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||
assert_eq!(fill(&n), white);
|
||||
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
|
||||
// Half way, mixed in RGB.
|
||||
let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_follow_their_formulas() {
|
||||
let b = [0.25, 0.5, 1.0];
|
||||
assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
|
||||
assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
|
||||
assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
|
||||
assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
|
||||
// Color keeps the backdrop's luminosity with the source's hue and saturation.
|
||||
let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
|
||||
assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_recolour_pixels_inside_their_alpha() {
|
||||
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
|
||||
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||
let Kind::Image(img) = &out.kind else { panic!() };
|
||||
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
|
||||
Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
|
||||
],
|
||||
transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
|
||||
};
|
||||
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
|
||||
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
|
||||
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
|
||||
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_overlay_lies_over_its_childrens_overlays() {
|
||||
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
|
||||
let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
|
||||
let mut group = solid(red, 0.5, Blend::Normal);
|
||||
group.kind = Kind::Group;
|
||||
group.children = vec![solid(blue, 1.0, Blend::Normal)];
|
||||
// The copy (2 nodes), the child's overlay (1) and the group's (2).
|
||||
let mut work = 5;
|
||||
let out = apply(&group, &mut work, &mut |_| {}).into_owned();
|
||||
assert_eq!(work, 0);
|
||||
let child = out.children.first().unwrap();
|
||||
// Blue first, then half of the group's red over it.
|
||||
assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
|
||||
assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
|
||||
// With no work left for the group's overlay, the child's still applies.
|
||||
let mut warnings = Vec::new();
|
||||
let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
|
||||
assert_eq!(fill(out.children.first().unwrap()), blue);
|
||||
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_stop_at_the_work_one_import_may_take() {
|
||||
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
|
||||
n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
|
||||
// 16 pixels and the node, for the copy and for the overlay: 34.
|
||||
let mut work = 40;
|
||||
let mut warnings = Vec::new();
|
||||
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
|
||||
assert_eq!((work, warnings.len()), (6, 0));
|
||||
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
|
||||
assert_eq!(work, 6);
|
||||
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,9 @@ pub struct Stroke {
|
||||
/// Gradient stops per gradient.
|
||||
const MAX_STOPS: usize = 1024;
|
||||
|
||||
/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs).
|
||||
const MAX_DASHES: usize = 64;
|
||||
|
||||
fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
match v {
|
||||
Value::Floats(f) => Some(f.clone()),
|
||||
@@ -283,7 +286,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
|
||||
(fills, stroke.into_iter().collect())
|
||||
}
|
||||
|
||||
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
||||
pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
|
||||
let s = r.s;
|
||||
let paint = descriptor(r, pen, local)?;
|
||||
if paint == Paint::None {
|
||||
@@ -295,6 +298,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
||||
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
|
||||
let width = if scales { weight * local.scale() } else { weight };
|
||||
let data = s.bytes(lsty, b"Data").unwrap_or_default();
|
||||
// Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a
|
||||
// stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their
|
||||
// owner made, where every such stroke is invisible in Affinity's own PDF export, and checked
|
||||
// against the public corpus's thumbnails.
|
||||
if data.get(10) == Some(&0) {
|
||||
return None;
|
||||
}
|
||||
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
|
||||
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
|
||||
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
|
||||
@@ -312,6 +322,10 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
|
||||
_ => Align::Center,
|
||||
};
|
||||
let dash = match s.field(lsty, b"Patn") {
|
||||
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
|
||||
r.warn("dash patterns of more than 64 lengths (imported as solid lines)");
|
||||
None
|
||||
}
|
||||
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
|
||||
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
|
||||
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
|
||||
|
||||
+246
-59
@@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
||||
&& (s.is(id, b"ImgN") || !has_tiles)
|
||||
{
|
||||
match original(r, name) {
|
||||
// HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels
|
||||
// stored as tiles are used instead (see `decode` when the tiles point back at the original).
|
||||
Ok(bytes) if is_heif(&bytes) && has_tiles => {}
|
||||
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
|
||||
@@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
||||
decode(r, bitmap, crop, world)
|
||||
}
|
||||
|
||||
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
|
||||
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand.
|
||||
fn is_heif(bytes: &[u8]) -> bool {
|
||||
bytes.get(4..8) == Some(b"ftyp".as_slice())
|
||||
&& bytes.get(8..12).is_some_and(|brand| {
|
||||
[b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice())
|
||||
})
|
||||
}
|
||||
|
||||
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read
|
||||
/// over `over` (document pixels) when given; outside its pixels a mask hides everything.
|
||||
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option<crate::model::Rect>) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
@@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Im
|
||||
r.warn("pixel masks in an unknown format (imported without them)");
|
||||
return Ok(None);
|
||||
}
|
||||
decode(r, bitmap, (0, 0, width, height), world)
|
||||
let crop = match over.map(|o| crop_to(o, world, width, height)) {
|
||||
Some(Some(c)) => c,
|
||||
// What it masks lies outside it: all hidden, which one black pixel says as well.
|
||||
Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })),
|
||||
None => (0, 0, width, height),
|
||||
};
|
||||
decode(r, bitmap, crop, world)
|
||||
}
|
||||
|
||||
/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover
|
||||
/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap.
|
||||
fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
|
||||
let inv = invert(world)?;
|
||||
let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y }));
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in pts {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return Some((0, 0, width, height));
|
||||
}
|
||||
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
|
||||
}
|
||||
let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32;
|
||||
let (cx0, cy0, cx1, cy1) =
|
||||
(clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height));
|
||||
(cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1))
|
||||
}
|
||||
|
||||
fn invert(m: Affine) -> Option<Affine> {
|
||||
let [a, b, c, d, e, f] = m.0;
|
||||
let det = a * d - b * c;
|
||||
if !(det.is_finite() && det.abs() > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
|
||||
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
|
||||
}
|
||||
|
||||
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
|
||||
@@ -99,33 +145,41 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Optio
|
||||
|
||||
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
|
||||
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
|
||||
decode_within(r, bitmap, crop, world, MAX_PIXELS)
|
||||
}
|
||||
|
||||
/// [`decode`] with at most `max_pixels` decoded pixels.
|
||||
fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let (x0, y0, x1, y1) = crop;
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let pixels = u64::from(w) * u64::from(h);
|
||||
if pixels > MAX_PIXELS {
|
||||
r.warn("pixel layers larger than 64 megapixels");
|
||||
return Ok(None);
|
||||
// Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their
|
||||
// resolution: every `step`th pixel of every `step`th row.
|
||||
let mut step = 1u32;
|
||||
while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels {
|
||||
step *= 2;
|
||||
if step > 1 << 12 {
|
||||
r.warn("pixel layers too large to read (left out)");
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
|
||||
let Some(left) = r.pixels_left.checked_sub(pixels) else {
|
||||
let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
|
||||
let Some(left) = r.budget.pixels.checked_sub(pixels) else {
|
||||
r.warn("pixel layers beyond the pixels one document may hold");
|
||||
return Ok(None);
|
||||
};
|
||||
r.pixels_left = left;
|
||||
r.budget.pixels = left;
|
||||
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
||||
let (channels, bps) = match format {
|
||||
Some(0) => (4, 1),
|
||||
Some(1) => (4, 2),
|
||||
Some(6) => (1, 1),
|
||||
Some(7) => (1, 2),
|
||||
Some(4) => {
|
||||
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
|
||||
(5, 1)
|
||||
}
|
||||
Some(4) => (5, 1),
|
||||
_ => {
|
||||
r.warn("pixel layers in grey, Lab or 32-bit formats");
|
||||
return Ok(None);
|
||||
@@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Tiles kept only in an original that can't be decoded here are left empty (transparent). Where
|
||||
// a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come
|
||||
// out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said.
|
||||
if needs_source && source.is_none() && channels > 1 {
|
||||
let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let alpha = states(channels);
|
||||
let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5))));
|
||||
if blank {
|
||||
r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)");
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
if step > 1 {
|
||||
// Its stored tiles must fit what is left of the import's extraction budget.
|
||||
let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum();
|
||||
if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total {
|
||||
r.warn("pixel layers too large to read (left out)");
|
||||
return Ok(None);
|
||||
}
|
||||
r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)");
|
||||
}
|
||||
let mut cache = CachedPixels { tiles: HashMap::new(), source };
|
||||
for c in 1..=channels {
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?);
|
||||
}
|
||||
let (w, h) = (w.div_ceil(step), h.div_ceil(step));
|
||||
let n = (w as usize) * (h as usize);
|
||||
let k = f64::from(step);
|
||||
let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world);
|
||||
if channels == 5 {
|
||||
// Ink amounts and alpha as stored (8 bits), interleaved.
|
||||
let mut cmyka = vec![0u8; n * 5];
|
||||
for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform }));
|
||||
}
|
||||
let mut rgba = vec![0u8; n * 4];
|
||||
let sample = |p: &Vec<u8>, i: usize| -> f64 {
|
||||
if bps == 2 {
|
||||
@@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
};
|
||||
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
|
||||
let (r2, g, b, a) = if channels == 1 {
|
||||
(v[0], v[0], v[0], 1.0)
|
||||
} else if channels == 5 {
|
||||
let k = 1.0 - v[3];
|
||||
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
|
||||
} else {
|
||||
(v[0], v[1], v[2], v[3])
|
||||
};
|
||||
let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) };
|
||||
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
}
|
||||
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
|
||||
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
|
||||
}
|
||||
|
||||
@@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
|
||||
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
||||
}
|
||||
|
||||
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
|
||||
/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state).
|
||||
fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec<u8>) {
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(tw, th, states)
|
||||
}
|
||||
|
||||
/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`.
|
||||
fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec<usize> {
|
||||
let (tw, th, states) = grid(s, bitmap, c, bps);
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut out = Vec::new();
|
||||
let mut next = 0usize;
|
||||
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
|
||||
if *state != 4 {
|
||||
continue;
|
||||
}
|
||||
let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE);
|
||||
if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) {
|
||||
out.push(next);
|
||||
}
|
||||
next += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of
|
||||
/// every `step`th row; `bps` bytes per sample.
|
||||
fn plane(
|
||||
r: &mut Reader,
|
||||
bitmap: ObjId,
|
||||
c: usize,
|
||||
bps: usize,
|
||||
(x0, y0, w, h): (u32, u32, u32, u32),
|
||||
step: u32,
|
||||
cache: &mut CachedPixels,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let CachedPixels { tiles: cache, source } = cache;
|
||||
let s = r.s;
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (tw, th, states) = grid(s, bitmap, c, bps);
|
||||
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
||||
let row = (w as usize) * bps;
|
||||
let mut out = vec![0u8; row * h as usize];
|
||||
let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize };
|
||||
let row = at.w.div_ceil(at.step) * bps;
|
||||
let mut out = vec![0u8; row * at.h.div_ceil(at.step)];
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut next = 0usize;
|
||||
@@ -267,7 +379,7 @@ fn plane(
|
||||
continue;
|
||||
}
|
||||
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
|
||||
if !cache.contains_key(&name) {
|
||||
let read = |r: &mut Reader| -> Result<Vec<u8>, Error> {
|
||||
let mut data = r.archive.read(&name)?;
|
||||
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
|
||||
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
|
||||
@@ -276,10 +388,20 @@ fn plane(
|
||||
if data.len() != TILE_BYTES {
|
||||
return Err(Error::Malformed("pixel tile size"));
|
||||
}
|
||||
Ok(data)
|
||||
};
|
||||
// A layer read at a lower resolution is too large to keep its tiles around.
|
||||
if at.step > 1 {
|
||||
let tile = read(r)?;
|
||||
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
if !cache.contains_key(&name) {
|
||||
let data = read(r)?;
|
||||
cache.insert(name.clone(), data);
|
||||
}
|
||||
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
3 => {
|
||||
@@ -289,9 +411,7 @@ fn plane(
|
||||
5 => {
|
||||
if let Some((source_width, pixels)) = source.as_ref() {
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
|
||||
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
|
||||
});
|
||||
copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0));
|
||||
continue;
|
||||
}
|
||||
r.warn("pixel layers drawn from an embedded image (left empty)");
|
||||
@@ -300,17 +420,38 @@ fn plane(
|
||||
_ => return Err(Error::Malformed("unknown pixel tile state")),
|
||||
};
|
||||
if let Some(v) = fill {
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
|
||||
copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
|
||||
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
||||
for y in oy.max(py0)..(oy + TILE).min(py1) {
|
||||
/// Where a plane's pixels come from: the crop (pixels) and the sampling step.
|
||||
struct Crop {
|
||||
bps: usize,
|
||||
x0: usize,
|
||||
y0: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
step: usize,
|
||||
}
|
||||
|
||||
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every
|
||||
/// `step`th pixel of every `step`th row.
|
||||
fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
|
||||
let row = c.w.div_ceil(c.step) * c.bps;
|
||||
let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps);
|
||||
for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) {
|
||||
if !(y - c.y0).is_multiple_of(c.step) {
|
||||
continue;
|
||||
}
|
||||
let out_row = (y - c.y0) / c.step * row;
|
||||
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
|
||||
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
|
||||
let px = x / c.bps - c.x0;
|
||||
if !px.is_multiple_of(c.step) {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) {
|
||||
*b = at(x - ox, y - oy);
|
||||
}
|
||||
}
|
||||
@@ -323,6 +464,15 @@ mod tests {
|
||||
use crate::model::Kind;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
#[test]
|
||||
fn heif_originals_are_recognised_by_their_brand() {
|
||||
assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0"));
|
||||
assert!(is_heif(b"\0\0\0\x1cftypavif"));
|
||||
assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo");
|
||||
assert!(!is_heif(&png()));
|
||||
assert!(!is_heif(b"ftyp"));
|
||||
}
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
@@ -333,18 +483,29 @@ mod tests {
|
||||
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
|
||||
/// not a claim that Affinity itself accepts the synthetic container.
|
||||
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
|
||||
fixture_in(source, origin, false)
|
||||
}
|
||||
|
||||
/// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored.
|
||||
fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec<u8> {
|
||||
let channels = if cmyk { 5 } else { 4 };
|
||||
let mut bitmap = vec![
|
||||
(tag(b"Frmt"), F::Enum(0, 0)),
|
||||
(tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)),
|
||||
(tag(b"BmpW"), F::I32(300)),
|
||||
(tag(b"BmpH"), F::I32(2)),
|
||||
(tag(b"LInf"), F::I32(origin)),
|
||||
(tag(b"TInf"), F::I32(0)),
|
||||
(tag(b"Bckg"), F::Entry("c/1".into())),
|
||||
];
|
||||
for c in 1..=4 {
|
||||
for c in 1..=channels {
|
||||
let states = match (cmyk, c) {
|
||||
(false, _) => vec![5, 4],
|
||||
(true, 5) => vec![4, 4],
|
||||
(true, _) => vec![5, 5],
|
||||
};
|
||||
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
|
||||
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states)));
|
||||
bitmap.push((
|
||||
tag(&super::tag(b"Idx", c)),
|
||||
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
|
||||
@@ -369,18 +530,44 @@ mod tests {
|
||||
blob.extend((source.len() as u32).to_le_bytes());
|
||||
blob.extend(source);
|
||||
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
|
||||
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
||||
synth::container(
|
||||
&[
|
||||
("doc.dat", &doc, Method::Zlib),
|
||||
("c/1", &block, Method::Zlib),
|
||||
("d/1", &tiles[0], Method::Zlib),
|
||||
("d/2", &tiles[1], Method::Zlib),
|
||||
("d/3", &tiles[2], Method::Zlib),
|
||||
("d/4", &tiles[3], Method::Zlib),
|
||||
],
|
||||
None,
|
||||
)
|
||||
let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
||||
let names: Vec<String> = (1..=channels).map(|c| format!("d/{c}")).collect();
|
||||
let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)];
|
||||
for (name, tile) in names.iter().zip(&tiles) {
|
||||
entries.push((name.as_str(), tile.as_slice(), Method::Zlib));
|
||||
}
|
||||
synth::container(&entries, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() {
|
||||
let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap();
|
||||
assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings);
|
||||
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() {
|
||||
let bytes = fixture(&png(), 0);
|
||||
let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap();
|
||||
let doc = archive.read("doc.dat").unwrap();
|
||||
let s = stream::parse(&doc).unwrap();
|
||||
let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap();
|
||||
let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0);
|
||||
// 20 × 2 pixels over a budget of 10: every other pixel of every other row.
|
||||
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap();
|
||||
assert_eq!((image.width, image.height), (10, 1));
|
||||
assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two");
|
||||
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
|
||||
for x in 0..10 {
|
||||
let sx = 2 * x;
|
||||
let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] };
|
||||
assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}");
|
||||
}
|
||||
assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)"));
|
||||
// Within budget, nothing changes.
|
||||
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap();
|
||||
assert_eq!((image.width, image.height), (20, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -261,6 +261,32 @@ mod archive {
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
|
||||
// Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's
|
||||
// window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than
|
||||
// the format permits for a block. 0.9.1 refuses it.
|
||||
#[test]
|
||||
fn a_zstd_block_larger_than_its_frames_window_is_refused() {
|
||||
const LEN: u32 = 100_000;
|
||||
let plain = vec![0x61u8; LEN as usize];
|
||||
let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None);
|
||||
// A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE
|
||||
// block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3.
|
||||
let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00];
|
||||
frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap());
|
||||
frame.push(0x61);
|
||||
// Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72).
|
||||
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
|
||||
let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize;
|
||||
let mut swapped = file[..76].to_vec();
|
||||
swapped.extend(&frame);
|
||||
swapped.extend(&file[76 + old_len..]);
|
||||
let new_fat = fat - old_len + frame.len();
|
||||
swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes());
|
||||
swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes());
|
||||
let mut a = Archive::open(&swapped, Limits::default()).unwrap();
|
||||
assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
|
||||
let big = vec![7u8; 1 << 20];
|
||||
|
||||
+330
-99
@@ -2,7 +2,7 @@
|
||||
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
|
||||
//! OpenType features) is left to the importing application.
|
||||
|
||||
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
|
||||
use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
|
||||
use crate::paint::{self, Color, Paint};
|
||||
use crate::stream::{ObjId, Value};
|
||||
|
||||
@@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20;
|
||||
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
|
||||
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
|
||||
|
||||
/// Ends a table cell in a table's story (Affinity's break glyph of kind 4).
|
||||
pub const CELL_BREAK: char = '\u{1F}';
|
||||
|
||||
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let s = r.s;
|
||||
let story = s.obj(id, b"StSt")?;
|
||||
let frame = s.obj(id, b"TxtH")?;
|
||||
let artistic = s.is(frame, b"ArFr");
|
||||
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
|
||||
let mut runs = Vec::new();
|
||||
let mut align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
};
|
||||
// Bound collection itself, including placeholders, before allocating the character buffer.
|
||||
let budget = MAX_CHARS - total;
|
||||
|
||||
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().take(budget + 1).collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let segments = s.objs(glyphs, b"Mixd");
|
||||
if segments.is_empty() {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
for seg in segments {
|
||||
let inline = s.objs(seg, b"Glys").len();
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
|
||||
placeholders |= inline > 0;
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let truncated = chars.len() > budget;
|
||||
if truncated {
|
||||
chars.truncate(budget);
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
}
|
||||
total += chars.len();
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
};
|
||||
let end = index.min(chars.len());
|
||||
if index > chars.len() || end < start {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
}
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let text = characters(&chars, start, end);
|
||||
start = end;
|
||||
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
|
||||
}
|
||||
if start < chars.len() {
|
||||
let text = characters(&chars, start, chars.len());
|
||||
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
|
||||
}
|
||||
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
if let Some(attrs) = s.obj(paragraph, b"Item") {
|
||||
let paragraph = paragraph_align(r, attrs);
|
||||
match align {
|
||||
None => align = Some(paragraph),
|
||||
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if placeholders {
|
||||
r.warn("text fields such as page numbers (left out of the text)");
|
||||
}
|
||||
let align = align.unwrap_or(Align::Left);
|
||||
let (runs, align) = story_runs(r, story, world);
|
||||
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
|
||||
let anchor_x = match align {
|
||||
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
|
||||
@@ -111,19 +35,256 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
r.warn("text frames with a curved outline (imported as rectangular frames)");
|
||||
}
|
||||
}
|
||||
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
|
||||
let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
|
||||
Some(Text {
|
||||
runs,
|
||||
align,
|
||||
anchor: Point { x: anchor_x, y: first_baseline },
|
||||
frame: if artistic { None } else { Some(bounds) },
|
||||
transform: world,
|
||||
text_transform,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rows and columns a table may have.
|
||||
const MAX_TABLE_LINES: usize = 4096;
|
||||
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
|
||||
const MAX_TABLE_CELLS: usize = 1 << 16;
|
||||
|
||||
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
|
||||
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let s = r.s;
|
||||
let story = s.obj(id, b"StSt")?;
|
||||
let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
|
||||
let bounds = |pos: Option<ObjId>| -> Option<Vec<f64>> {
|
||||
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
|
||||
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
|
||||
(v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
|
||||
};
|
||||
let columns = bounds(s.obj(tabl, b"CPos"))?;
|
||||
let rows = bounds(s.obj(tabl, b"RPos"))?;
|
||||
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
|
||||
let count = ncols.checked_mul(nrows)?;
|
||||
if count > MAX_TABLE_CELLS {
|
||||
r.warn("tables with more than 65536 cells (left out)");
|
||||
return None;
|
||||
}
|
||||
let Some(left) = r.budget.cells.checked_sub(count) else {
|
||||
r.warn("tables beyond the cells one document may hold (left out)");
|
||||
return None;
|
||||
};
|
||||
r.budget.cells = left;
|
||||
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
|
||||
// Formatting that doesn't match the grid would land on the wrong cells (and merging them,
|
||||
// move text between cells): only text then.
|
||||
let cell_objs = if cell_objs.len() == count {
|
||||
cell_objs
|
||||
} else {
|
||||
r.warn("tables whose cells don't match their grid (cell formatting left out)");
|
||||
Vec::new()
|
||||
};
|
||||
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
|
||||
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
|
||||
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
|
||||
if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
|
||||
cell.inset = inset;
|
||||
}
|
||||
cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
|
||||
Some(1) => VAlign::Center,
|
||||
Some(2) => VAlign::Bottom,
|
||||
_ => VAlign::Top,
|
||||
};
|
||||
// A cell merged into the one on its left; the first column has nothing to its left.
|
||||
cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
|
||||
}
|
||||
// The story holds the text of every cell that isn't merged away, row by row, each ended by a
|
||||
// cell break.
|
||||
let owner_list: Vec<usize> = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
|
||||
let mut owners = owner_list.into_iter();
|
||||
let mut target = owners.next();
|
||||
let mut extra = false;
|
||||
let (runs, _) = story_runs(r, story, world);
|
||||
for run in runs {
|
||||
let mut parts = run.text.split(CELL_BREAK).peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !part.is_empty() {
|
||||
match target.and_then(|i| cells.get_mut(i)) {
|
||||
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
None => extra = true,
|
||||
}
|
||||
}
|
||||
if parts.peek().is_some() {
|
||||
target = owners.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
if extra {
|
||||
r.warn("table text beyond its last cell (left out)");
|
||||
}
|
||||
let mut edges = |tag: &[u8; 4], expected: usize| -> Vec<Option<crate::paint::Stroke>> {
|
||||
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
|
||||
if objs.len() != expected {
|
||||
r.warn("tables whose lines don't match their grid (lines left out)");
|
||||
return Vec::new();
|
||||
}
|
||||
objs.into_iter()
|
||||
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
|
||||
(Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let vertical = edges(b"CEdg", (ncols + 1) * nrows);
|
||||
let horizontal = edges(b"REdg", ncols * (nrows + 1));
|
||||
if let Some(format) = s.obj(tabl, b"TFmt") {
|
||||
let filled =
|
||||
s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
|
||||
if filled {
|
||||
r.warn("table cell fills (left out)");
|
||||
}
|
||||
}
|
||||
let text_transform = text_scale(r, id).then(r.type_base);
|
||||
Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
|
||||
}
|
||||
|
||||
/// A story's character runs, each with its paragraph's format, and the first paragraph's
|
||||
/// alignment. Table cells end with [`CELL_BREAK`].
|
||||
pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<TextRun>, Align) {
|
||||
let s = r.s;
|
||||
let mut runs = Vec::new();
|
||||
let mut first_align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
};
|
||||
// Bound collection itself, including placeholders, before allocating the character buffer:
|
||||
// by the story's limit and by what the document's other stories left.
|
||||
let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total));
|
||||
|
||||
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().take(budget + 1).collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors, cell ends) each take one index before
|
||||
// the segment text.
|
||||
let segments = s.objs(glyphs, b"Mixd");
|
||||
if segments.is_empty() {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
for seg in segments {
|
||||
for inline in s.objs(seg, b"Glys") {
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
|
||||
v.push(CELL_BREAK);
|
||||
} else {
|
||||
v.push('\u{FFFC}');
|
||||
placeholders = true;
|
||||
}
|
||||
}
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let truncated = chars.len() > budget;
|
||||
if truncated {
|
||||
chars.truncate(budget);
|
||||
if budget < MAX_CHARS - total {
|
||||
r.warn("text beyond the characters one document may hold (truncated)");
|
||||
} else {
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
}
|
||||
}
|
||||
total += chars.len();
|
||||
// Paragraph formats by end index; a run without an index covers the rest of the block.
|
||||
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
|
||||
let mut open: Option<Paragraph> = None;
|
||||
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
let a = paragraph(r, attrs);
|
||||
first_align.get_or_insert(a.align);
|
||||
match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
|
||||
Some(end) => paragraphs.push((end, a)),
|
||||
None => match open {
|
||||
None => open = Some(a),
|
||||
Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
// Sorted by end (they are in order in every file), so both lookups below are binary
|
||||
// searches: a block can have as many paragraphs and runs as characters.
|
||||
paragraphs.sort_by_key(|(end, _)| *end);
|
||||
let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default();
|
||||
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
|
||||
cuts.dedup();
|
||||
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
|
||||
// Split at paragraph ends so every run has one paragraph format.
|
||||
let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
|
||||
let mut a = from;
|
||||
for &c in inside.iter().chain(std::iter::once(&to)) {
|
||||
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
|
||||
a = c;
|
||||
}
|
||||
};
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
};
|
||||
let end = index.min(chars.len());
|
||||
if index > chars.len() || end < start {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
}
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
|
||||
start = end;
|
||||
}
|
||||
if start < chars.len() {
|
||||
push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
|
||||
}
|
||||
if truncated {
|
||||
break;
|
||||
}
|
||||
}
|
||||
r.budget.chars = r.budget.chars.saturating_sub(total);
|
||||
if placeholders {
|
||||
r.warn("text fields such as page numbers (left out of the text)");
|
||||
}
|
||||
(runs, first_align.unwrap_or(Align::Left))
|
||||
}
|
||||
|
||||
fn characters(chars: &[char], start: usize, end: usize) -> String {
|
||||
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
|
||||
}
|
||||
|
||||
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
|
||||
fn push_run(
|
||||
r: &mut Reader,
|
||||
runs: &mut Vec<TextRun>,
|
||||
attrs: Option<ObjId>,
|
||||
text: String,
|
||||
world: Affine,
|
||||
attribute_budget: &mut Option<usize>,
|
||||
paragraph: Paragraph,
|
||||
) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let run = match attrs {
|
||||
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
|
||||
Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) },
|
||||
_ => {
|
||||
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
|
||||
if attrs.is_none() {
|
||||
@@ -137,10 +298,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text:
|
||||
italic: false,
|
||||
features: Vec::new(),
|
||||
all_caps: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
size: 12.0 * r.dpi / 72.0,
|
||||
tracking: 0.0,
|
||||
h_scale: 1.0,
|
||||
leading: None,
|
||||
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
|
||||
stroke: None,
|
||||
paragraph,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -195,13 +361,33 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
||||
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
|
||||
}
|
||||
}
|
||||
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
|
||||
{
|
||||
r.warn("outlined (stroked) text");
|
||||
}
|
||||
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
|
||||
r.warn("horizontally scaled text");
|
||||
// Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
|
||||
let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
|
||||
(true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
|
||||
_ => None,
|
||||
};
|
||||
// Its gradient stops and dashes are owned data too, charged like the fill's.
|
||||
if let Some(st) = &stroke {
|
||||
let stops = match &st.paint {
|
||||
Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>()),
|
||||
_ => 0,
|
||||
};
|
||||
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
|
||||
if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
|
||||
stroke = None;
|
||||
}
|
||||
}
|
||||
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
|
||||
let mut decoration = |i: usize| match slot(ints, i, int) {
|
||||
Some(1) => true,
|
||||
Some(0) | None => false,
|
||||
Some(_) => {
|
||||
r.warn("text decorations of an unknown kind (left out)");
|
||||
false
|
||||
}
|
||||
};
|
||||
let (underline, strikethrough) = (decoration(0), decoration(1));
|
||||
let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
|
||||
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
|
||||
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
|
||||
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
|
||||
@@ -220,8 +406,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
||||
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
|
||||
r.warn("font width variants (exact face depends on the installed PostScript font)");
|
||||
}
|
||||
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
|
||||
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
|
||||
let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
|
||||
let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
|
||||
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
|
||||
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
|
||||
let weight = if (1..=1000).contains(&weight) {
|
||||
@@ -238,10 +424,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
|
||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||
features,
|
||||
all_caps,
|
||||
underline,
|
||||
strikethrough,
|
||||
size,
|
||||
tracking,
|
||||
h_scale,
|
||||
leading,
|
||||
fill,
|
||||
stroke,
|
||||
paragraph: Paragraph::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +451,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
|
||||
const MAX_FONT_NAME: usize = 256;
|
||||
|
||||
fn font_name(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
||||
if value.len() > MAX_FONT_NAME {
|
||||
r.warn("font names longer than 256 bytes (the default font is used)");
|
||||
return String::new();
|
||||
}
|
||||
metadata_string(r, budget, value)
|
||||
}
|
||||
|
||||
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
|
||||
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
|
||||
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
|
||||
@@ -303,13 +505,18 @@ fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Op
|
||||
(features, all_caps)
|
||||
}
|
||||
|
||||
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
|
||||
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
|
||||
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
|
||||
r.warn("paragraph indents or spacing (imported with defaults)");
|
||||
}
|
||||
match slot(r.s.field(p, b"Ints"), 0, int) {
|
||||
/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic
|
||||
/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading
|
||||
/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the
|
||||
/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space
|
||||
/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing
|
||||
/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the
|
||||
/// thumbnail of a document whose owner made it earlier.
|
||||
fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph {
|
||||
let doubles = r.s.field(p, b"Doub");
|
||||
let ints = r.s.field(p, b"Ints");
|
||||
let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0);
|
||||
let align = match slot(ints, 0, int) {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
Some(3) => Align::Justify,
|
||||
@@ -318,5 +525,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
r.warn("an unknown paragraph alignment (imported as left)");
|
||||
Align::Left
|
||||
}
|
||||
};
|
||||
let leading = match slot(ints, 1, int) {
|
||||
Some(2) => Some(at(1)).filter(|l| *l > 0.0),
|
||||
Some(0) | None => None,
|
||||
Some(_) => {
|
||||
r.warn("paragraph leading of an unknown kind (imported as automatic)");
|
||||
None
|
||||
}
|
||||
};
|
||||
let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0);
|
||||
Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading }
|
||||
}
|
||||
|
||||
/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set.
|
||||
/// Neither the frame's own transform nor its groups' scale its type (checked against the
|
||||
/// thumbnails of tables and frames inside scaled groups).
|
||||
fn text_scale(r: &mut Reader, id: ObjId) -> Affine {
|
||||
match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) {
|
||||
Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]),
|
||||
Some(_) => {
|
||||
r.warn("text frames with an invalid text scale (imported unscaled)");
|
||||
Affine::IDENTITY
|
||||
}
|
||||
None => Affine::IDENTITY,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
|
||||
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
|
||||
//! a file links or embeds the same content. Original synthetic documents in the reader's
|
||||
//! accepted schema; no Affinity software or files were used.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod support {
|
||||
pub mod text_fixture;
|
||||
}
|
||||
|
||||
use support::text_fixture::*;
|
||||
use vectorcraft_affinity::{
|
||||
Document, Error, Limits,
|
||||
model::{Kind, Node},
|
||||
read,
|
||||
synth::{self, F, Method, tag},
|
||||
};
|
||||
|
||||
type Field = (vectorcraft_affinity::stream::Tag, F);
|
||||
|
||||
/// A document of one spread holding `nodes`, plus these other archive entries.
|
||||
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
|
||||
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
|
||||
let mut next_id = 1000;
|
||||
for (_, value) in &mut root {
|
||||
shared_objects(value, &mut next_id);
|
||||
}
|
||||
let data = synth::stream(&root);
|
||||
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
|
||||
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
|
||||
synth::container(&all, None)
|
||||
}
|
||||
|
||||
fn open(nodes: Vec<F>) -> Document {
|
||||
read(&file(nodes, &[]), Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
fn all(nodes: &[Node]) -> Vec<&Node> {
|
||||
let mut out = Vec::new();
|
||||
for n in nodes {
|
||||
out.push(n);
|
||||
out.extend(all(&n.children));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn warned(d: &Document, needle: &str) -> bool {
|
||||
d.warnings.iter().any(|w| w.contains(needle))
|
||||
}
|
||||
|
||||
fn story(text: &str) -> F {
|
||||
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
|
||||
}
|
||||
|
||||
fn artistic(story: F) -> F {
|
||||
F::Obj(
|
||||
tag(b"TxtA"),
|
||||
vec![
|
||||
(tag(b"StSt"), story),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
|
||||
fn grid(cols: usize, rows: usize) -> Vec<Field> {
|
||||
let pos = |n: usize| {
|
||||
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
|
||||
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
|
||||
};
|
||||
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
|
||||
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
|
||||
}
|
||||
|
||||
fn table(frame: F) -> F {
|
||||
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
|
||||
}
|
||||
|
||||
fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
|
||||
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
|
||||
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
|
||||
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
|
||||
let t = tables(&d);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
|
||||
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
|
||||
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
|
||||
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
|
||||
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||
assert_eq!(tables(&d).len(), 4);
|
||||
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_story_is_read_within_the_characters_of_one_document() {
|
||||
// A story of a million characters linked from twenty text layers: 16 million in all.
|
||||
let text = "x".repeat(1 << 20);
|
||||
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
|
||||
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||
let chars: usize = all(&d.spreads[0].nodes)
|
||||
.into_iter()
|
||||
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
|
||||
.sum();
|
||||
assert_eq!(chars, 16 << 20);
|
||||
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
|
||||
// 200 000 paragraphs, each with its own run and format.
|
||||
let n = 200_000;
|
||||
let text = "a\u{2029}".repeat(n);
|
||||
let fmt =
|
||||
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8(&text)),
|
||||
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
|
||||
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
|
||||
],
|
||||
);
|
||||
let started = std::time::Instant::now();
|
||||
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
|
||||
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
|
||||
assert_eq!(t.runs.len(), n);
|
||||
// (About half a second; quadratic matching took over half a minute on the same machine.)
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A shape node of `size` px.
|
||||
fn rect(size: f64) -> F {
|
||||
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
|
||||
}
|
||||
|
||||
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
|
||||
fn embedded(children: Vec<F>) -> F {
|
||||
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
|
||||
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
|
||||
if !children.is_empty() {
|
||||
fields.push((tag(b"Chld"), F::Shared(children)));
|
||||
}
|
||||
F::Obj(tag(b"EmbN"), fields)
|
||||
}
|
||||
|
||||
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
|
||||
let mut entry = b"EmDc\0\0\0\0".to_vec();
|
||||
entry.extend_from_slice(inner);
|
||||
file(nodes, &[("edc/1", &entry)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_documents_content_stays_with_its_node() {
|
||||
// Read before the node's own children, which must not take it.
|
||||
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
|
||||
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
|
||||
let node = &d.spreads[0].nodes[0];
|
||||
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
|
||||
assert!(node.children.iter().all(|c| c.children.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_one_document_many_times_shares_one_budget() {
|
||||
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
|
||||
// a whole read may take (each embedding used to have an allowance of its own).
|
||||
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
|
||||
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
|
||||
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
|
||||
// Embedded fewer times, it opens.
|
||||
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
|
||||
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
|
||||
}
|
||||
@@ -103,13 +103,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_and_lab_losses_are_reported() {
|
||||
fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() {
|
||||
// CMYK layers keep their inks for the importer to convert through a colour profile.
|
||||
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
|
||||
assert_eq!(images.len(), 1);
|
||||
assert_eq!((images[0].width, images[0].height), (64, 48));
|
||||
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
|
||||
assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
|
||||
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
|
||||
let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() };
|
||||
assert_eq!(pixels.len(), 64 * 48 * 5);
|
||||
assert_eq!(crc32fast::hash(pixels), 0x56b1f659);
|
||||
assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings);
|
||||
assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile");
|
||||
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
|
||||
assert!(images.is_empty());
|
||||
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
|
||||
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
||||
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
|
||||
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
|
||||
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
|
||||
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
|
||||
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
|
||||
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
|
||||
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
|
||||
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
|
||||
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
|
||||
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
|
||||
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
|
||||
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
||||
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
||||
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
|
||||
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
|
||||
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
|
||||
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
|
||||
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
|
||||
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
|
||||
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
||||
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
|
||||
patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"]
|
||||
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[]
|
||||
patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[]
|
||||
patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
|
||||
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
|
||||
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
|
||||
patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
|
||||
patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
|
||||
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
||||
patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
||||
patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
|
||||
patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
|
||||
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
|
||||
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
|
||||
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
|
||||
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
@@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String {
|
||||
}
|
||||
"texts"
|
||||
}
|
||||
Kind::Table(t) => {
|
||||
*counts.entry("table_cells").or_default() += t.cells.len();
|
||||
"tables"
|
||||
}
|
||||
Kind::Image(_) => "images",
|
||||
Kind::MasterInstance { .. } => "master_instances",
|
||||
Kind::Unsupported => "unsupported",
|
||||
};
|
||||
*counts.entry(key).or_default() += 1;
|
||||
|
||||
@@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
|
||||
|
||||
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
|
||||
// fixture objects independent shared IDs without making the fixture descriptions noisy.
|
||||
fn shared_objects(value: &mut F, next_id: &mut u32) {
|
||||
pub fn shared_objects(value: &mut F, next_id: &mut u32) {
|
||||
match value {
|
||||
F::Shared(items) => {
|
||||
for item in items {
|
||||
|
||||
@@ -11,7 +11,7 @@ mod support {
|
||||
use support::text_fixture::*;
|
||||
use vectorcraft_affinity::{
|
||||
Document, Limits,
|
||||
model::{Align, Kind, Text},
|
||||
model::{Align, Kind, Paragraph, Text},
|
||||
paint::{Color, Paint},
|
||||
synth::{F, tag},
|
||||
};
|
||||
@@ -108,7 +108,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() {
|
||||
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
|
||||
assert_eq!(content(text(&d)), "one\u{2029}two");
|
||||
assert_eq!(text(&d).align, Align::Right);
|
||||
assert!(warning(&d, "mixed paragraph alignments"));
|
||||
assert!(warning(&d, "mixed paragraph formats"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -194,10 +194,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
|
||||
assert_eq!(texts.len(), 2);
|
||||
assert!(texts[0].runs[0].all_caps);
|
||||
assert_eq!(texts[1].runs[0].features, ["smcp"]);
|
||||
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
|
||||
// Indents and paragraph spacing are read (values in document pixels, as these fixtures set
|
||||
// them in Affinity's paragraph panel).
|
||||
let paragraphs = |name: &str| -> Vec<Paragraph> {
|
||||
let d = load(name);
|
||||
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
|
||||
}
|
||||
assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings);
|
||||
d.spreads[0]
|
||||
.nodes
|
||||
.iter()
|
||||
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None })
|
||||
.flat_map(|t| t.runs.iter().map(|r| r.paragraph))
|
||||
.collect()
|
||||
};
|
||||
let p = paragraphs("patchy-text-indent.af");
|
||||
assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::<Vec<_>>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]);
|
||||
let p = paragraphs("patchy-text-para-spacing.af");
|
||||
assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::<Vec<_>>(), [(9.0, 17.0)]);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
@@ -247,9 +259,19 @@ fn excessive_feature_settings_are_bounded_and_reported() {
|
||||
assert!(warning(&d, "more than 256 text feature settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_longer_than_256_bytes_are_dropped() {
|
||||
// Each paragraph and run carries its font's name: a huge one would be copied with each.
|
||||
let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]);
|
||||
let r = &text(&d).runs[0];
|
||||
assert!(r.family.is_empty() && r.postscript == "Inter-Regular");
|
||||
assert!(warning(&d, "font names longer than 256 bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
|
||||
// The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`).
|
||||
let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
let settings = F::Obj(
|
||||
tag(b"OtAt"),
|
||||
@@ -266,11 +288,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
}
|
||||
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
|
||||
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
|
||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||
// About 7.7 kB of names and features per run: the budget runs out after about 2200 runs.
|
||||
runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]);
|
||||
let t = text(&d);
|
||||
assert_eq!(content(t), "x".repeat(1000));
|
||||
assert_eq!(t.runs.len(), 1000);
|
||||
assert_eq!(content(t), "x".repeat(4000));
|
||||
assert_eq!(t.runs.len(), 4000);
|
||||
let metadata: usize = t
|
||||
.runs
|
||||
.iter()
|
||||
@@ -279,7 +302,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
.map(|s| s.len() + std::mem::size_of::<String>())
|
||||
.sum();
|
||||
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
|
||||
assert_eq!(t.runs[0].family.len(), 16 * 1024);
|
||||
assert_eq!(t.runs[0].family.len(), 256);
|
||||
assert_eq!(t.runs[0].features.len(), 256);
|
||||
assert!(t.runs.last().unwrap().family.is_empty());
|
||||
assert!(t.runs.last().unwrap().features.is_empty());
|
||||
@@ -320,3 +343,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() {
|
||||
assert!(warning(&d, "text attribute budget exceeded"));
|
||||
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_gradient_outlines_are_bounded_without_losing_characters() {
|
||||
let color =
|
||||
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
|
||||
let mut colors = vec![color];
|
||||
colors.extend((1..1024).map(|_| F::Ref(43)));
|
||||
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
|
||||
let pen = F::Obj(
|
||||
tag(b"FilG"),
|
||||
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
|
||||
);
|
||||
let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]);
|
||||
// `Objs` 1 and 2: the outline's paint and line style, shared by every run.
|
||||
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]);
|
||||
let mut runs = vec![run(1, Some(attrs))];
|
||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||
let t = text(&d);
|
||||
assert_eq!(content(t), "x".repeat(1000));
|
||||
assert_eq!(t.runs.len(), 1000);
|
||||
let stop_count: usize = t
|
||||
.runs
|
||||
.iter()
|
||||
.filter_map(|r| r.stroke.as_ref())
|
||||
.map(|s| match &s.paint {
|
||||
Paint::Gradient(g) => g.stops.len(),
|
||||
_ => 0,
|
||||
})
|
||||
.sum();
|
||||
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
|
||||
let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") };
|
||||
assert_eq!(first.stops.len(), 1024);
|
||||
assert!(t.runs.last().unwrap().stroke.is_none());
|
||||
assert!(warning(&d, "text attribute budget exceeded"));
|
||||
}
|
||||
@@ -33,6 +33,9 @@ const WIDE_LINES: &str =
|
||||
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
|
||||
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
|
||||
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
/// One segment of a subpath, cubics for every curve.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -973,8 +976,8 @@ impl<'a> Scene<'a> {
|
||||
|
||||
/// Image `key` as a file in the chosen format, and its size in pixels.
|
||||
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
|
||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?;
|
||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?;
|
||||
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?;
|
||||
let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?;
|
||||
let size = (img.width(), img.height());
|
||||
let mut bytes = vec![];
|
||||
let written = match self.o.raster {
|
||||
@@ -992,7 +995,7 @@ impl<'a> Scene<'a> {
|
||||
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
|
||||
}
|
||||
};
|
||||
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?;
|
||||
written.map_err(|e| format!("an image could not be written: {e}"))?;
|
||||
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +526,24 @@ fn images_link_png_or_jpeg_files() {
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_without_pixels_are_left_out_with_one_note() {
|
||||
// One image has no data in the document, and the other image's data is empty.
|
||||
let mut d = doc_with(|d| {
|
||||
["raster-1", "raster-2"]
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let im = ImageObject { key: key.into(), width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None });
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert!(entities(&out).is_empty() && out.images.is_empty());
|
||||
assert_eq!(out.warnings, ["an image has no pixels and was left out"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_leaves_out_art_outside_the_region() {
|
||||
let d = shapes_doc();
|
||||
|
||||
+394
-11
@@ -31,6 +31,75 @@ impl GradientKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a gradient mixes the colours between its stops (the Gradient panel's Method).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum GradientMethod {
|
||||
/// Mixed in display RGB, as gradients always were.
|
||||
#[default]
|
||||
Classic,
|
||||
/// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to
|
||||
/// yellow passes through a light blue-grey rather than a dull mid grey.
|
||||
Perceptual,
|
||||
}
|
||||
|
||||
impl GradientMethod {
|
||||
/// Parse a method name (`classic`, `perceptual`; any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Classic => "Classic",
|
||||
Self::Perceptual => "Perceptual",
|
||||
}
|
||||
}
|
||||
pub fn is_classic(&self) -> bool {
|
||||
*self == Self::Classic
|
||||
}
|
||||
/// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an
|
||||
/// RGB colour between the two).
|
||||
pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color {
|
||||
match self {
|
||||
Self::Classic => Color::lerp(a, b, t),
|
||||
Self::Perceptual => {
|
||||
use crate::libraries::{oklab_to_srgb, srgb_to_oklab};
|
||||
let (p, q, t) = (srgb_to_oklab(a.to_rgb()), srgb_to_oklab(b.to_rgb()), f64::from(t));
|
||||
let [r, g, bl] = oklab_to_srgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t);
|
||||
let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 };
|
||||
Color::rgb(c(r), c(g), c(bl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring
|
||||
/// expanded stops may stray from the perceptual mix: under half a colour level.
|
||||
const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
|
||||
/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is
|
||||
/// halved to keep within [`PERCEPTUAL_TOLERANCE`].
|
||||
const PERCEPTUAL_DEPTH: u32 = 10;
|
||||
|
||||
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
|
||||
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
|
||||
/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order.
|
||||
fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec<f32>) {
|
||||
let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb();
|
||||
let (c0, c1) = (at(v0), at(v1));
|
||||
let stray = [0.25, 0.5, 0.75]
|
||||
.into_iter()
|
||||
.flat_map(|u: f32| {
|
||||
let c = at(v0 + (v1 - v0) * u);
|
||||
(0..3).map(move |k| (c0[k] + (c1[k] - c0[k]) * u - c[k]).abs())
|
||||
})
|
||||
.fold(0.0, f32::max);
|
||||
if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE {
|
||||
let m = (v0 + v1) / 2.0;
|
||||
perceptual_steps(a, b, (v0, m), depth + 1, out);
|
||||
out.push(m);
|
||||
perceptual_steps(a, b, (m, v1), depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientStop {
|
||||
/// 0..=1 along the gradient.
|
||||
@@ -75,19 +144,39 @@ fn half() -> f32 {
|
||||
pub struct Gradient {
|
||||
pub kind: GradientKind,
|
||||
pub stops: Vec<GradientStop>,
|
||||
/// Dither: a fill of this gradient is drawn as pixels with fine noise of up to a colour level
|
||||
/// added, which hides the steps (banding) between the levels 8-bit colour has.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dither: bool,
|
||||
/// How the colours between the stops are mixed (Classic unless set; written only when not).
|
||||
#[serde(default, skip_serializing_if = "GradientMethod::is_classic")]
|
||||
pub method: GradientMethod,
|
||||
}
|
||||
|
||||
impl Default for Gradient {
|
||||
/// Illustrator's default "White, Black" gradient.
|
||||
fn default() -> Self {
|
||||
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
|
||||
Self::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)])
|
||||
}
|
||||
}
|
||||
|
||||
impl Gradient {
|
||||
/// Colour and opacity at `t` (honours midpoints).
|
||||
/// A gradient of `kind` through `stops`, with the default options.
|
||||
pub fn new(kind: GradientKind, stops: Vec<GradientStop>) -> Self {
|
||||
Self { kind, stops, dither: false, method: GradientMethod::Classic }
|
||||
}
|
||||
/// Colour and opacity at `t` (honours midpoints and the method).
|
||||
pub fn sample(&self, t: f32) -> (Color, f32) {
|
||||
self.sample_with(t, Color::lerp)
|
||||
self.sample_with(t, |a, b, v| self.method.mix(a, b, v))
|
||||
}
|
||||
/// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it
|
||||
/// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual
|
||||
/// gradient's as [`Self::sample`] mixes them.
|
||||
pub fn sample_keep_model(&self, t: f32) -> (Color, f32) {
|
||||
match self.method {
|
||||
GradientMethod::Classic => self.sample_with(t, Color::lerp_keep_model),
|
||||
m => self.sample_with(t, |a, b, v| m.mix(a, b, v)),
|
||||
}
|
||||
}
|
||||
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
|
||||
/// model rather than display RGB).
|
||||
@@ -115,20 +204,47 @@ impl Gradient {
|
||||
None => (Color::BLACK, 1.0),
|
||||
}
|
||||
}
|
||||
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
|
||||
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without
|
||||
/// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that
|
||||
/// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it).
|
||||
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
|
||||
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
|
||||
}
|
||||
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
|
||||
/// on the gradient's own stops).
|
||||
/// on the gradient's own stops and a Perceptual gradient's other in-between stops).
|
||||
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
|
||||
self.stops.iter().enumerate().flat_map(move |(i, s)| {
|
||||
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
|
||||
let next = self.stops.get(i + 1);
|
||||
let off_centre = (s.midpoint - 0.5).abs() > 1e-3;
|
||||
// Classic: a stop at the midpoint, when it is off-centre.
|
||||
let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| {
|
||||
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||
let (c, o) = self.sample(t);
|
||||
(t, c, o, Some(s.midpoint))
|
||||
});
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
|
||||
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
|
||||
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
|
||||
let mut vs = Vec::new();
|
||||
perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
|
||||
vs.push(0.5);
|
||||
perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
|
||||
let m = s.midpoint.clamp(0.01, 0.99);
|
||||
vs.into_iter().map(move |v| {
|
||||
let mid = v == 0.5;
|
||||
let u = if mid {
|
||||
s.midpoint
|
||||
} else if v < 0.5 {
|
||||
2.0 * v * m
|
||||
} else {
|
||||
m + (v - 0.5) * 2.0 * (1.0 - m)
|
||||
};
|
||||
let t = s.offset + (n.offset - s.offset) * u;
|
||||
let o = s.opacity + (n.opacity - s.opacity) * v;
|
||||
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
|
||||
})
|
||||
});
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
|
||||
})
|
||||
}
|
||||
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
|
||||
@@ -172,7 +288,7 @@ impl Gradient {
|
||||
);
|
||||
}
|
||||
stops.push(stop(1.0, to));
|
||||
Gradient { kind: GradientKind::Linear, stops }
|
||||
Gradient::new(GradientKind::Linear, stops)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +650,117 @@ impl GradientPaint {
|
||||
f.transform(a);
|
||||
}
|
||||
}
|
||||
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
|
||||
/// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a
|
||||
/// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix
|
||||
/// linearly between the stops, as SVG's and PDF's gradients do)?
|
||||
pub fn draws_as_pixels(&self, fill: bool) -> bool {
|
||||
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
|
||||
}
|
||||
/// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of
|
||||
/// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours
|
||||
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a
|
||||
/// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets
|
||||
/// [`dither_noise`] before it is rounded to a level.
|
||||
pub fn pixels(
|
||||
&self,
|
||||
bounds: Rect,
|
||||
area: Rect,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
fill: bool,
|
||||
rgb: &dyn Fn(&Color) -> [f32; 3],
|
||||
) -> impl Iterator<Item = [u8; 4]> + use<> {
|
||||
self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], fill, rgb)
|
||||
}
|
||||
/// [`Self::pixels`] for the cells in columns `cells[0]` and rows `cells[1]` of the grid only,
|
||||
/// row by row: the same pixels, noise and all, the whole grid has there.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn pixels_within(
|
||||
&self,
|
||||
bounds: Rect,
|
||||
area: Rect,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
cells: [std::ops::Range<u16>; 2],
|
||||
fill: bool,
|
||||
rgb: &dyn Fn(&Color) -> [f32; 3],
|
||||
) -> impl Iterator<Item = [u8; 4]> + use<> {
|
||||
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither);
|
||||
let ramp = Ramp::new(&self.gradient, rgb);
|
||||
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
|
||||
let (x0, y0) = (area.x0, area.y0);
|
||||
let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end));
|
||||
down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| {
|
||||
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
|
||||
let c = ramp.at(geom.param_at(kind, p) as f32);
|
||||
let level = |k: usize| {
|
||||
let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 };
|
||||
(c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
[level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient's colours ready to be sampled pixel by pixel, in a colour space of the caller's
|
||||
/// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's
|
||||
/// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space
|
||||
/// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's
|
||||
/// vector gradients mix them).
|
||||
pub struct Ramp {
|
||||
stops: Vec<(f32, [f32; 4])>,
|
||||
}
|
||||
|
||||
impl Ramp {
|
||||
pub fn new(g: &Gradient, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Self {
|
||||
let stops = g
|
||||
.expanded()
|
||||
.map(|(t, c, o, _)| {
|
||||
let [r, gr, b] = rgb(&c);
|
||||
(t, [r, gr, b, o])
|
||||
})
|
||||
.collect();
|
||||
Self { stops }
|
||||
}
|
||||
/// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black
|
||||
/// without stops).
|
||||
pub fn at(&self, t: f32) -> [f32; 4] {
|
||||
let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] };
|
||||
// Not after the first stop (or not a number): its colour.
|
||||
if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) {
|
||||
return first;
|
||||
}
|
||||
for w in self.stops.windows(2) {
|
||||
let ((a, ca), (b, cb)) = (w[0], w[1]);
|
||||
if t <= b {
|
||||
let u = if b - a > 1e-9 { (t - a) / (b - a) } else { 1.0 };
|
||||
return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u);
|
||||
}
|
||||
}
|
||||
self.stops.last().map_or([0.0; 4], |s| s.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed of [`dither_noise`]: fixed, so a dithered gradient draws and exports the same pixels on
|
||||
/// every run.
|
||||
const DITHER_SEED: u32 = 0x5eed_d17e;
|
||||
|
||||
/// An integer hash that mixes every input bit into every output bit (the "lowbias32" mix).
|
||||
fn mix32(mut v: u32) -> u32 {
|
||||
v ^= v >> 16;
|
||||
v = v.wrapping_mul(0x7feb_352d);
|
||||
v ^= v >> 15;
|
||||
v = v.wrapping_mul(0x846c_a68b);
|
||||
v ^ (v >> 16)
|
||||
}
|
||||
|
||||
/// Dither noise for colour channel `channel` of pixel (`x`, `y`), in colour levels: -1..1,
|
||||
/// densest around 0 (the sum of two uniform values: noise that hides steps between levels without
|
||||
/// a pattern of its own), the same for the same pixel on every run.
|
||||
pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 {
|
||||
let h = mix32(x ^ mix32(y ^ mix32(channel ^ DITHER_SEED)));
|
||||
((h & 0xffff) as f32 + (h >> 16) as f32) / 65535.0 - 1.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -582,9 +809,8 @@ mod tests {
|
||||
#[test]
|
||||
fn reverse_mirrors_midpoints() {
|
||||
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
|
||||
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
|
||||
let three =
|
||||
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
|
||||
let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]);
|
||||
let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]);
|
||||
for g in [two, three] {
|
||||
let mut r = g.clone();
|
||||
r.reverse();
|
||||
@@ -825,4 +1051,161 @@ mod tests {
|
||||
h.keep_focal_from(&before);
|
||||
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixels_sample_the_ramp_at_each_cell_centre() {
|
||||
// White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end.
|
||||
let mut g = GradientPaint::new(Gradient::default());
|
||||
g.gradient.stops[0].midpoint = 0.3;
|
||||
g.gradient.stops[1].opacity = 0.5;
|
||||
let b = Rect::new(0.0, 0.0, 10.0, 2.0);
|
||||
let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect();
|
||||
assert_eq!(px.len(), 20);
|
||||
let ramp = Ramp::new(&g.gradient, &Color::to_rgb);
|
||||
for (i, p) in px.iter().take(10).enumerate() {
|
||||
let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8);
|
||||
assert_eq!(*p, c, "pixel {i}");
|
||||
}
|
||||
assert_eq!(px[3], px[13], "rows alike");
|
||||
// The midpoint stop: grey at 30%; padded past the ends.
|
||||
assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3));
|
||||
assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]);
|
||||
assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]);
|
||||
assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
fn blue_yellow(method: GradientMethod) -> Gradient {
|
||||
let mut g = Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
|
||||
);
|
||||
g.method = method;
|
||||
g
|
||||
}
|
||||
|
||||
/// Illustrator 30's Perceptual method mixes colours in a perceptually even way
|
||||
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
|
||||
/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey
|
||||
/// a display RGB mix gives, and its lightness climbs evenly.
|
||||
#[test]
|
||||
fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() {
|
||||
let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb();
|
||||
assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}");
|
||||
let p = blue_yellow(GradientMethod::Perceptual);
|
||||
let [r, g, b] = p.sample(0.5).0.to_rgb();
|
||||
assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]);
|
||||
assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]);
|
||||
let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0];
|
||||
let steps: Vec<f64> = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect();
|
||||
assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}");
|
||||
// Ends and opacity as before.
|
||||
assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff");
|
||||
assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00");
|
||||
}
|
||||
|
||||
/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual
|
||||
/// curve (the midpoint diamond among them), more where it bends, close enough that the linear
|
||||
/// ramp between them stays within half a colour level of the curve; a segment that hardly
|
||||
/// bends needs few.
|
||||
#[test]
|
||||
fn perceptual_segments_expand_into_stops_on_the_curve() {
|
||||
let pairs = [
|
||||
(Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)),
|
||||
(Color::BLACK, Color::rgb(1.0, 0.0, 0.0)),
|
||||
(Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)),
|
||||
(Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE),
|
||||
];
|
||||
for (a, b) in pairs {
|
||||
let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
|
||||
p.method = GradientMethod::Perceptual;
|
||||
p.stops[0].midpoint = 0.3;
|
||||
p.stops[1].opacity = 0.5;
|
||||
let ex: Vec<_> = p.expanded().collect();
|
||||
assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len());
|
||||
assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
|
||||
assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint");
|
||||
assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6));
|
||||
let ramp = Ramp::new(&p, &Color::to_rgb);
|
||||
for k in 0..=2000 {
|
||||
let t = k as f32 / 2000.0;
|
||||
let (c, o) = p.sample(t);
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
|
||||
assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}");
|
||||
}
|
||||
}
|
||||
let mut flat = Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))],
|
||||
);
|
||||
flat.method = GradientMethod::Perceptual;
|
||||
assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint");
|
||||
}
|
||||
|
||||
/// Classic gradients expand as they always did: their stops, and a stop at each off-centre
|
||||
/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before).
|
||||
#[test]
|
||||
fn classic_gradients_expand_as_before() {
|
||||
let mut g = Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![
|
||||
GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) },
|
||||
GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) },
|
||||
GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) },
|
||||
GradientStop::new(1.0, Color::gray(0.3)),
|
||||
],
|
||||
);
|
||||
// The expansion gradients had before they had a method.
|
||||
let before: Vec<_> = g
|
||||
.stops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, s)| {
|
||||
let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
|
||||
let t = s.offset + (n.offset - s.offset) * s.midpoint;
|
||||
let (c, o) = g.sample_with(t, Color::lerp);
|
||||
(t, c, o, Some(s.midpoint))
|
||||
});
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(g.expanded().collect::<Vec<_>>(), before);
|
||||
assert_eq!(before.len(), 6);
|
||||
assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp));
|
||||
assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model));
|
||||
g.method = GradientMethod::Perceptual;
|
||||
assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix");
|
||||
}
|
||||
|
||||
/// The method is written only when Perceptual, so files from before it (and Classic
|
||||
/// gradients) read as Classic and new Classic files stay as they were.
|
||||
#[test]
|
||||
fn method_is_saved_only_when_perceptual() {
|
||||
let classic = serde_json::to_value(Gradient::default()).unwrap();
|
||||
assert!(classic.get("method").is_none(), "{classic}");
|
||||
let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap();
|
||||
assert_eq!(old.method, GradientMethod::Classic);
|
||||
let p = blue_yellow(GradientMethod::Perceptual);
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v["method"], "Perceptual");
|
||||
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), p);
|
||||
assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual));
|
||||
assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic));
|
||||
assert_eq!(GradientMethod::parse("smooth"), None);
|
||||
}
|
||||
|
||||
/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there,
|
||||
/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke,
|
||||
/// dithered or not.
|
||||
#[test]
|
||||
fn perceptual_gradients_draw_as_pixels() {
|
||||
for fill in [true, false] {
|
||||
assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill));
|
||||
assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill));
|
||||
let mut radial = blue_yellow(GradientMethod::Perceptual);
|
||||
radial.kind = GradientKind::Radial;
|
||||
radial.dither = true;
|
||||
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -16,7 +16,7 @@ pub mod swatch;
|
||||
|
||||
pub use blend::BlendMode;
|
||||
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
|
||||
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop};
|
||||
pub use libraries::SwatchLibrary;
|
||||
pub use swatch::{Swatch, SwatchGroup, default_swatches};
|
||||
|
||||
@@ -146,6 +146,8 @@ impl Color {
|
||||
} else {
|
||||
60.0 * ((r - g) / d + 4.0)
|
||||
};
|
||||
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
|
||||
let h = if h >= 360.0 { 0.0 } else { h };
|
||||
let s = if max <= 0.0 { 0.0 } else { d / max };
|
||||
[h, s, max]
|
||||
}
|
||||
@@ -378,6 +380,18 @@ impl Paint {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// This stroke paint as the fill of the stroke's outline, so the outline looks as the stroke
|
||||
/// did: without dither, which strokes ignore ([`GradientPaint::draws_as_pixels`]).
|
||||
pub fn stroke_as_fill(&self) -> Paint {
|
||||
match self {
|
||||
Paint::Gradient(g) if g.gradient.dither => {
|
||||
let mut g = g.clone();
|
||||
g.gradient.dither = false;
|
||||
Paint::Gradient(g)
|
||||
}
|
||||
p => p.clone(),
|
||||
}
|
||||
}
|
||||
/// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`].
|
||||
pub fn registration() -> Self {
|
||||
Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 }
|
||||
@@ -426,6 +440,12 @@ impl Paint {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hue_below_360() {
|
||||
let h = Color::Rgb { r: 1.0, g: 0.0, b: 1e-7 }.to_hsb()[0];
|
||||
assert!((0.0..360.0).contains(&h), "hue {h}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_roundtrip() {
|
||||
let c = Color::from_hex("#ff8000").unwrap();
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
|
||||
// ---------- colour construction ----------
|
||||
|
||||
/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped.
|
||||
fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
pub(crate) fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
|
||||
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
|
||||
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
|
||||
@@ -113,7 +113,12 @@ fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
|
||||
|
||||
/// OKLab `[l, a, b]` of a colour's display RGB.
|
||||
pub fn to_oklab(c: &Color) -> [f64; 3] {
|
||||
let [r, g, b] = c.to_rgb_uncalibrated().map(|v| {
|
||||
srgb_to_oklab(c.to_rgb_uncalibrated())
|
||||
}
|
||||
|
||||
/// OKLab `[l, a, b]` of sRGB `rgb` (0..=1, gamma encoded).
|
||||
pub(crate) fn srgb_to_oklab(rgb: [f32; 3]) -> [f64; 3] {
|
||||
let [r, g, b] = rgb.map(|v| {
|
||||
let v = f64::from(v);
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
});
|
||||
@@ -171,7 +176,7 @@ fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
|
||||
/// A gradient swatch through `stops` of (offset, colour, opacity).
|
||||
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
|
||||
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
|
||||
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false }
|
||||
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false }
|
||||
}
|
||||
|
||||
/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1),
|
||||
|
||||
@@ -721,13 +721,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sample() -> SwatchLibrary {
|
||||
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
||||
kind: GradientKind::Linear,
|
||||
stops: vec![
|
||||
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![
|
||||
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
|
||||
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
|
||||
],
|
||||
})));
|
||||
))));
|
||||
SwatchLibrary {
|
||||
name: "Brand */ Colours".into(),
|
||||
swatches: vec![
|
||||
@@ -822,15 +822,15 @@ mod tests {
|
||||
name: "Positions".into(),
|
||||
swatches: vec![Swatch {
|
||||
name: "Ramp".into(),
|
||||
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient {
|
||||
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
kind,
|
||||
stops: vec![
|
||||
vec![
|
||||
GradientStop::new(0.0, Color::BLACK),
|
||||
GradientStop::new(0.125, Color::rgb8(255, 0, 0)),
|
||||
GradientStop::new(0.12890625, Color::WHITE),
|
||||
GradientStop::new(1.0, Color::rgb8(0, 0, 255)),
|
||||
],
|
||||
}))),
|
||||
)))),
|
||||
global: false,
|
||||
spot: false,
|
||||
}],
|
||||
|
||||
@@ -123,13 +123,13 @@ fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
|
||||
.collect();
|
||||
let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch {
|
||||
name: name.into(),
|
||||
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
|
||||
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient::new(
|
||||
kind,
|
||||
stops: vec![
|
||||
vec![
|
||||
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
|
||||
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
|
||||
],
|
||||
}))),
|
||||
)))),
|
||||
global: false,
|
||||
spot: false,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ zune-jpeg = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
jpeg-encoder = { workspace = true }
|
||||
|
||||
+69
-11
@@ -89,7 +89,7 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
|
||||
out.stops = offsets
|
||||
.iter()
|
||||
.map(|&o| {
|
||||
let (color, opacity) = g.sample_with(o, lerp_color);
|
||||
let (color, opacity) = g.sample_keep_model(o);
|
||||
let (swatch, tint) = link_at(g, o);
|
||||
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
|
||||
})
|
||||
@@ -579,11 +579,18 @@ impl Lerp {
|
||||
|
||||
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
|
||||
pub fn at(&self, t: f64) -> Node {
|
||||
self.at_eased(t, t)
|
||||
}
|
||||
|
||||
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
|
||||
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
|
||||
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
|
||||
let (a, b) = (&self.a, &self.b);
|
||||
let base = if t < 0.5 { a } else { b };
|
||||
let mut n = match &self.shape {
|
||||
Shape::Group { children, clip } => {
|
||||
let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip });
|
||||
let mut g =
|
||||
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
|
||||
g.isolate = base.isolate;
|
||||
g.knockout = base.knockout;
|
||||
g
|
||||
@@ -593,8 +600,8 @@ impl Lerp {
|
||||
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
|
||||
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
|
||||
p.p = x.p.lerp(y.p, t);
|
||||
p.color = lerp_color(&x.color, &y.color, t as f32);
|
||||
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32);
|
||||
p.color = lerp_color(&x.color, &y.color, tc as f32);
|
||||
p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
|
||||
for h in 0..4 {
|
||||
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
|
||||
}
|
||||
@@ -620,7 +627,7 @@ impl Lerp {
|
||||
Shape::Placed => {
|
||||
let mut n = base.clone();
|
||||
match (&mut n.kind, &a.kind, &b.kind) {
|
||||
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t),
|
||||
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
|
||||
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
|
||||
*o = lerp_affine(*x, *y, t)
|
||||
}
|
||||
@@ -657,8 +664,8 @@ impl Lerp {
|
||||
}
|
||||
};
|
||||
n.id = NodeId(0);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
|
||||
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
|
||||
n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
|
||||
n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
|
||||
n.blend = base.blend;
|
||||
n.visible = true;
|
||||
n.locked = false;
|
||||
@@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
|
||||
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
||||
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
||||
/// the same place (by character) of both.
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
|
||||
o.xf = lerp_affine(x.xf, y.xf, t);
|
||||
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
||||
(&mut o.kind, &x.kind, &y.kind)
|
||||
@@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
|
||||
}
|
||||
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||
let mut start = 0usize;
|
||||
let tf = t as f32;
|
||||
let tf = tc as f32;
|
||||
let mut resized = false;
|
||||
for r in &mut o.runs {
|
||||
let len = r.text.chars().count();
|
||||
@@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
let n = blend_step_count(a, b, spec.spacing, len);
|
||||
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
||||
for j in 1..=n {
|
||||
let t = j as f64 / (n + 1) as f64;
|
||||
let mut s = lerp.at(t);
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
let u = j as f64 / (n + 1) as f64;
|
||||
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||
let mut s = lerp.at_eased(t, tc);
|
||||
if let Some(r) = &rail {
|
||||
r.place(&mut s, i, t);
|
||||
}
|
||||
@@ -1134,6 +1143,55 @@ mod tests {
|
||||
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_keeps_the_ends_and_stays_monotone() {
|
||||
use crate::live::{BlendEase, BlendEaseType as K};
|
||||
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
|
||||
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
|
||||
let e = BlendEase { kind, ramp };
|
||||
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
|
||||
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
|
||||
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
|
||||
assert_eq!(e.apply(f64::NAN), 0.0);
|
||||
if ramp == 0.0 {
|
||||
assert_eq!(e.apply(0.25), 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
let e = |kind| BlendEase { kind, ramp: 1.0 };
|
||||
assert!(e(K::In).apply(0.25) < 0.25);
|
||||
assert!(e(K::Out).apply(0.25) > 0.25);
|
||||
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
|
||||
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_easing_moves_colours_apart_from_positions() {
|
||||
let rect = |x: f64, c: Color| {
|
||||
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
|
||||
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
|
||||
Arc::new(n)
|
||||
};
|
||||
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
|
||||
let mid = |spec: &BlendSpec| {
|
||||
let out = blend_expand(&keys, spec);
|
||||
let n = &out[1];
|
||||
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
|
||||
};
|
||||
// Position easing runs along a spine: Align to Path gives the blend one.
|
||||
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
|
||||
let (x0, p0) = mid(&base);
|
||||
// Colour easing alone leaves the position and moves the colour.
|
||||
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
|
||||
let (x1, p1) = mid(&colour);
|
||||
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
|
||||
// Position easing alone leaves the colour and moves the position.
|
||||
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
|
||||
let (x2, p2) = mid(&pos);
|
||||
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
|
||||
assert_eq!(mid(&pos).0, x2, "deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_lerp_turns_the_short_way() {
|
||||
let x = Affine::rotate(170f64.to_radians());
|
||||
|
||||
+136
-3
@@ -44,6 +44,7 @@ pub mod perspective;
|
||||
mod pixels;
|
||||
pub use pixels::MAX_RASTER_ALLOC;
|
||||
pub mod placed_document;
|
||||
pub mod plugin_data;
|
||||
pub mod profiles;
|
||||
pub mod puppet;
|
||||
pub mod range;
|
||||
@@ -99,7 +100,7 @@ pub use graph::{
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live_paint::{GapOptions, GapSize};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
@@ -109,7 +110,7 @@ pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
pub use placed_document::PlacedDocument;
|
||||
pub use placed_document::{PageBox, PlacedDocument};
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use puppet::{PuppetPin, PuppetPins};
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
@@ -378,7 +379,9 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
|
||||
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
|
||||
let s = s.trim();
|
||||
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
|
||||
// A leading `+` is a sign (`+5`), like `-`.
|
||||
let sign = usize::from(s.starts_with('+'));
|
||||
let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign);
|
||||
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
|
||||
let unit = match s.get(num_end..)?.trim() {
|
||||
"" => None,
|
||||
@@ -935,6 +938,136 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
/// Is everything but the layers the same in `other`, as the next version of the document after
|
||||
/// an edit of its art? The id counter and the id index are left out, and art outside the layers
|
||||
/// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it
|
||||
/// doesn't change: quick for every frame of an animation.
|
||||
pub fn same_outside_layers(&self, other: &Self) -> bool {
|
||||
let same_art = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b));
|
||||
let same_pattern = |a: &PatternDef, b: &PatternDef| {
|
||||
same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() }
|
||||
};
|
||||
let Self {
|
||||
version,
|
||||
title,
|
||||
template,
|
||||
units,
|
||||
ruler_origin,
|
||||
rulers,
|
||||
artboard_ruler_origins,
|
||||
color_mode,
|
||||
artboards,
|
||||
layers: _,
|
||||
swatches,
|
||||
swatch_groups,
|
||||
graphic_styles,
|
||||
char_styles,
|
||||
para_styles,
|
||||
text_threads,
|
||||
symbols,
|
||||
graph_designs,
|
||||
guides,
|
||||
views,
|
||||
saved_selections,
|
||||
variables,
|
||||
grid,
|
||||
raster_effects_ppi,
|
||||
images,
|
||||
patterns,
|
||||
pattern_edit,
|
||||
mask_edit,
|
||||
next_id: _,
|
||||
unknown,
|
||||
page_isolate,
|
||||
page_knockout,
|
||||
spot_use_lab,
|
||||
setup,
|
||||
paste_remembers_layers,
|
||||
metadata,
|
||||
raster_effects,
|
||||
last_view,
|
||||
transparency_grid,
|
||||
layers_open,
|
||||
color_profiles,
|
||||
export_settings,
|
||||
extra,
|
||||
slices,
|
||||
slices_clip_to_artboard,
|
||||
print_setup,
|
||||
assets,
|
||||
svg_scripts,
|
||||
puppet,
|
||||
new_art,
|
||||
dimensions,
|
||||
index: _,
|
||||
} = self;
|
||||
*version == other.version
|
||||
&& *title == other.title
|
||||
&& *template == other.template
|
||||
&& *units == other.units
|
||||
&& *ruler_origin == other.ruler_origin
|
||||
&& *rulers == other.rulers
|
||||
&& *artboard_ruler_origins == other.artboard_ruler_origins
|
||||
&& *color_mode == other.color_mode
|
||||
&& *artboards == other.artboards
|
||||
&& *swatches == other.swatches
|
||||
&& *swatch_groups == other.swatch_groups
|
||||
&& *graphic_styles == other.graphic_styles
|
||||
&& *char_styles == other.char_styles
|
||||
&& *para_styles == other.para_styles
|
||||
&& *text_threads == other.text_threads
|
||||
&& symbols.len() == other.symbols.len()
|
||||
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
|
||||
&& graph_designs.len() == other.graph_designs.len()
|
||||
&& graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
|
||||
&& *guides == other.guides
|
||||
&& *views == other.views
|
||||
&& *saved_selections == other.saved_selections
|
||||
&& *variables == other.variables
|
||||
&& *grid == other.grid
|
||||
&& *raster_effects_ppi == other.raster_effects_ppi
|
||||
&& *images == other.images
|
||||
&& patterns.len() == other.patterns.len()
|
||||
&& patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b))
|
||||
&& match (pattern_edit, &other.pattern_edit) {
|
||||
(Some(a), Some(b)) => {
|
||||
a.pattern == b.pattern
|
||||
&& a.layer == b.layer
|
||||
&& match (&a.original, &b.original) {
|
||||
(Some(a), Some(b)) => same_pattern(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
}
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
&& *mask_edit == other.mask_edit
|
||||
&& *unknown == other.unknown
|
||||
&& *page_isolate == other.page_isolate
|
||||
&& *page_knockout == other.page_knockout
|
||||
&& *spot_use_lab == other.spot_use_lab
|
||||
&& *setup == other.setup
|
||||
&& *paste_remembers_layers == other.paste_remembers_layers
|
||||
&& *metadata == other.metadata
|
||||
&& *raster_effects == other.raster_effects
|
||||
&& *last_view == other.last_view
|
||||
&& *transparency_grid == other.transparency_grid
|
||||
&& *layers_open == other.layers_open
|
||||
&& *color_profiles == other.color_profiles
|
||||
&& *export_settings == other.export_settings
|
||||
&& *extra == other.extra
|
||||
&& *slices == other.slices
|
||||
&& *slices_clip_to_artboard == other.slices_clip_to_artboard
|
||||
&& *print_setup == other.print_setup
|
||||
&& *assets == other.assets
|
||||
&& *svg_scripts == other.svg_scripts
|
||||
&& match (puppet, &other.puppet) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
&& *new_art == other.new_art
|
||||
&& *dimensions == other.dimensions
|
||||
}
|
||||
|
||||
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
|
||||
/// isn't a number).
|
||||
pub fn raster_ppi(&self) -> f64 {
|
||||
|
||||
+187
-6
@@ -60,12 +60,75 @@ pub enum BlendOrientation {
|
||||
AlignToPath,
|
||||
}
|
||||
|
||||
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BlendEaseType {
|
||||
#[default]
|
||||
In,
|
||||
Out,
|
||||
InOut,
|
||||
OutIn,
|
||||
}
|
||||
|
||||
impl BlendEaseType {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::In => "easeIn",
|
||||
Self::Out => "easeOut",
|
||||
Self::InOut => "easeInOut",
|
||||
Self::OutIn => "easeOutIn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendEase {
|
||||
#[serde(default)]
|
||||
pub kind: BlendEaseType,
|
||||
#[serde(default, deserialize_with = "blend_ramp")]
|
||||
pub ramp: f64,
|
||||
}
|
||||
|
||||
impl BlendEase {
|
||||
pub fn is_linear(&self) -> bool {
|
||||
self.ramp <= 0.0
|
||||
}
|
||||
|
||||
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.kind == BlendEaseType::In && self.ramp == 0.0
|
||||
}
|
||||
|
||||
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
|
||||
/// describes the four distributions, not their numeric curves; these curves are original.
|
||||
pub fn apply(&self, u: f64) -> f64 {
|
||||
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let eased = match self.kind {
|
||||
BlendEaseType::In => u * u,
|
||||
BlendEaseType::Out => u * (2.0 - u),
|
||||
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
|
||||
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
|
||||
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
|
||||
};
|
||||
u + r * (eased - u)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendSpec {
|
||||
#[serde(default)]
|
||||
pub spacing: BlendSpacing,
|
||||
#[serde(default)]
|
||||
pub orientation: BlendOrientation,
|
||||
/// Acceleration of the steps' shapes and positions along the spine between two keys.
|
||||
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
|
||||
pub ease: BlendEase,
|
||||
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
|
||||
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
|
||||
pub color_shift: f64,
|
||||
/// Replaced spine. `None` = the straight lines between the key objects' centres.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spine: Option<PathData>,
|
||||
@@ -228,12 +291,15 @@ pub struct GradientMesh {
|
||||
pub points: Vec<MeshPoint>,
|
||||
}
|
||||
|
||||
/// One tessellated piece of a mesh (a small quad with a solid colour).
|
||||
/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and
|
||||
/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads
|
||||
/// meet without a step.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MeshQuad {
|
||||
pub pts: [Point; 4],
|
||||
pub color: Color,
|
||||
pub opacity: f32,
|
||||
pub corners: [(Color, f32); 4],
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh → Appearance.
|
||||
@@ -401,11 +467,49 @@ fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
|
||||
cum
|
||||
}
|
||||
|
||||
/// Most pieces [`map_nonlinear`] splits one segment into.
|
||||
const MAX_PIECES: usize = 64;
|
||||
|
||||
/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`.
|
||||
pub fn pieces(len: f64, piece: f64, most: usize) -> usize {
|
||||
((len / piece).ceil() as usize).clamp(1, most.max(1))
|
||||
}
|
||||
|
||||
/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so
|
||||
/// that the pieces past each segment's first number at most `room`: `piece` when they already do,
|
||||
/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path,
|
||||
/// so pieces of the total length over `room` fit; with no room every segment stays one piece.
|
||||
pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 {
|
||||
let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add);
|
||||
if added <= room {
|
||||
return piece;
|
||||
}
|
||||
if room == 0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
// A NaN length is always one piece.
|
||||
piece.max(lens.iter().filter(|len| !len.is_nan()).sum::<f64>() / room as f64)
|
||||
}
|
||||
|
||||
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
|
||||
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
|
||||
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
|
||||
pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData {
|
||||
let max_piece = max_piece.max(1e-3);
|
||||
map_nonlinear_within(path, max_piece, usize::MAX, f)
|
||||
}
|
||||
|
||||
/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more:
|
||||
/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]),
|
||||
/// a coarser approximation of the image curve. Geometry effects map within their anchor budget.
|
||||
pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData {
|
||||
let mut max_piece = max_piece.max(1e-3);
|
||||
// Each segment adds one anchor per piece past its first.
|
||||
let room = max.saturating_sub(path.anchor_count());
|
||||
let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add);
|
||||
if segs.saturating_mul(MAX_PIECES - 1) > room {
|
||||
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect();
|
||||
max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room);
|
||||
}
|
||||
let mut subs = Vec::with_capacity(path.subpaths.len());
|
||||
for sp in &path.subpaths {
|
||||
let n = sp.anchors.len();
|
||||
@@ -421,7 +525,7 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
|
||||
res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind });
|
||||
if i < segs {
|
||||
let c = seg_cubic(sp, i);
|
||||
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
|
||||
let k = pieces(poly_len(&c), max_piece, MAX_PIECES);
|
||||
for j in 0..k {
|
||||
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
|
||||
if let Some(last) = res.last_mut() {
|
||||
@@ -769,7 +873,7 @@ impl GradientMesh {
|
||||
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
||||
}
|
||||
|
||||
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre.
|
||||
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
|
||||
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
|
||||
if !self.is_valid() {
|
||||
return vec![];
|
||||
@@ -777,18 +881,27 @@ impl GradientMesh {
|
||||
let n = n.clamp(1, 64);
|
||||
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
|
||||
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
|
||||
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
|
||||
for r in 0..self.rows as usize {
|
||||
for c in 0..self.cols as usize {
|
||||
for j in 0..=n {
|
||||
for i in 0..=n {
|
||||
grid[j * (n + 1) + i] = self.eval(r, c, i as f64 / n as f64, j as f64 / n as f64);
|
||||
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
|
||||
grid[j * (n + 1) + i] = self.eval(r, c, u, v);
|
||||
colors[j * (n + 1) + i] = self.color_at(r, c, u, v);
|
||||
}
|
||||
}
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64);
|
||||
let g = |a: usize, b: usize| grid[b * (n + 1) + a];
|
||||
out.push(MeshQuad { pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], color, opacity });
|
||||
let k = |a: usize, b: usize| colors[b * (n + 1) + a];
|
||||
out.push(MeshQuad {
|
||||
pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)],
|
||||
color,
|
||||
opacity,
|
||||
corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1757,10 +1870,78 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
|
||||
o
|
||||
}
|
||||
|
||||
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
|
||||
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
|
||||
/// commands still refuse such input (`ease_param`).
|
||||
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
|
||||
}
|
||||
|
||||
fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
blend_number(d, 0.0, 1.0)
|
||||
}
|
||||
|
||||
fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
|
||||
blend_number(d, -100.0, 100.0)
|
||||
}
|
||||
|
||||
/// Whether an optional stored color shift has its unchanged default.
|
||||
fn is_zero(v: &f64) -> bool {
|
||||
*v == 0.0
|
||||
}
|
||||
|
||||
impl BlendSpec {
|
||||
/// Shift the color transition independently of shape placement. The exponential curve is
|
||||
/// original; the documented behavior specifies the direction, not numeric sample values.
|
||||
pub fn color_position(&self, u: f64) -> f64 {
|
||||
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
|
||||
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of
|
||||
/// failing the whole document.
|
||||
#[test]
|
||||
fn blend_easing_numbers_clamp_when_read() {
|
||||
use serde::de::value::{Error, F64Deserializer};
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0));
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0));
|
||||
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap();
|
||||
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0));
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(blend_ramp(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||
assert_eq!(blend_color_shift(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3578: a non-linear map within an anchor budget splits its segments into longer pieces,
|
||||
/// down to one a segment, and matches the plain map where the budget leaves room.
|
||||
#[test]
|
||||
fn map_nonlinear_within_keeps_to_its_anchor_budget() {
|
||||
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let f = |p: Point| Point::new(p.x + (p.y / 10.0).sin(), p.y);
|
||||
let full = map_nonlinear(&square, 1.0, f);
|
||||
assert_eq!(full.anchor_count(), 4 * 64);
|
||||
assert_eq!(map_nonlinear_within(&square, 1.0, 4 * 64, f), full);
|
||||
// Every side takes the same pieces: of 400 / (max - 4) pt, so 12 a side for 50 anchors.
|
||||
for (max, want) in [(0, 4), (4, 4), (5, 4), (50, 4 * 12), (255, 4 * 63)] {
|
||||
assert_eq!(map_nonlinear_within(&square, 1.0, max, f).anchor_count(), want, "{max}");
|
||||
}
|
||||
// Lengths that are not numbers stay one piece; with no room every segment is one piece.
|
||||
assert_eq!(fit_piece(&[f64::NAN, 10.0], 1.0, 64, 5), 2.0);
|
||||
assert_eq!(fit_piece(&[f64::INFINITY, 10.0], 1.0, 64, 0), f64::INFINITY);
|
||||
assert_eq!(pieces(f64::INFINITY, f64::INFINITY, 64), 1);
|
||||
assert_eq!(pieces(10.0, 1.0, 0), 1);
|
||||
}
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
|
||||
+26
-15
@@ -3,6 +3,7 @@
|
||||
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
|
||||
//! before File Info existed load unchanged.
|
||||
|
||||
use chrono::{Datelike, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// File Info → Copyright Status.
|
||||
@@ -118,23 +119,26 @@ pub fn now_unix() -> Option<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic
|
||||
/// Gregorian; years clamped to 0–9999).
|
||||
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years
|
||||
/// clamped to 0–9999).
|
||||
pub fn civil(t: i64) -> [i64; 6] {
|
||||
// Clamp first so every step stays in range for any input.
|
||||
// Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31),
|
||||
// exactly as the old code did. The clamped instant is always inside chrono's range, so the
|
||||
// conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp.
|
||||
let t = t.clamp(-62_167_219_200, 253_402_300_799);
|
||||
let days = t.div_euclid(86_400);
|
||||
let secs = t.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60]
|
||||
let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
|
||||
match (date, time) {
|
||||
(Some(d), Some(tm)) => {
|
||||
let year = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
|
||||
[year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())]
|
||||
}
|
||||
_ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix seconds → `2026-10-05T14:03:00Z`.
|
||||
@@ -151,8 +155,15 @@ mod tests {
|
||||
fn dates_and_statuses() {
|
||||
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
|
||||
// The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999,
|
||||
// exactly as the old code did: inputs far past chrono's range land on the clamp bounds,
|
||||
// never on the epoch.
|
||||
assert_eq!(civil(i64::MIN)[0], 0);
|
||||
assert_eq!(civil(i64::MAX)[0], 9999);
|
||||
assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z");
|
||||
assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z");
|
||||
assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z");
|
||||
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
|
||||
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
|
||||
assert_eq!(CopyrightStatus::parse("maybe"), None);
|
||||
|
||||
@@ -523,8 +523,8 @@ pub enum NodeKind {
|
||||
Mesh(GradientMesh),
|
||||
/// Live Repeat (radial / grid / mirror) of source art.
|
||||
Repeat(RepeatSpec),
|
||||
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
|
||||
/// [`crate::placed_document`]).
|
||||
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai`
|
||||
/// file, linked and locked (see [`crate::placed_document`]).
|
||||
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
|
||||
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
|
||||
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
|
||||
@@ -1301,10 +1301,8 @@ impl Node {
|
||||
self.projected(Some(b)).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
// The characters' strokes paint around the glyphs, scaled with the type.
|
||||
let k = Scaling::factor(t.xf).unwrap_or(1.0);
|
||||
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
|
||||
let o = self.appearance.outset().max(chars);
|
||||
// The characters' strokes paint around the glyphs.
|
||||
let o = self.appearance.outset().max(t.char_stroke_outset());
|
||||
self.geometric(symbols).map(|b| b.inflate(o, o))
|
||||
}
|
||||
// An image is measured by its frame, without its own fills and strokes.
|
||||
|
||||
@@ -279,7 +279,7 @@ impl PatternDef {
|
||||
out
|
||||
}
|
||||
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
|
||||
/// centre `(0, 0)` excluded).
|
||||
/// centre `(0, 0)` excluded), in row-major order (not paint order).
|
||||
pub fn preview_offsets(&self) -> Vec<Vec2> {
|
||||
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
||||
let mut out = vec![];
|
||||
@@ -292,6 +292,38 @@ impl PatternDef {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Offsets of every pattern-editing preview copy, including the centre tile, in the same paint
|
||||
/// order [`offsets_covering`] uses for [`Overlap`].
|
||||
pub fn preview_offsets_in_paint_order(&self) -> Vec<Vec2> {
|
||||
let k = (self.copies.clamp(1, 15) / 2) as i64;
|
||||
let (i0, i1, j0, j1) = (-k, k, -k, k);
|
||||
let mut out = vec![];
|
||||
let rows: Vec<i64> = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() };
|
||||
for &j in &rows {
|
||||
let cols: Vec<i64> = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() };
|
||||
for i in cols {
|
||||
out.push(self.offset(i, j));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Preview copies drawn before and after the centre tile in [`Self::preview_offsets_in_paint_order`],
|
||||
/// for one dimmed composite each (the centre tile is drawn at full strength between them).
|
||||
pub fn preview_offsets_dimmed_sides(&self) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let mut before = vec![];
|
||||
let mut after = vec![];
|
||||
let mut past_center = false;
|
||||
for o in self.preview_offsets_in_paint_order() {
|
||||
if o.x == 0.0 && o.y == 0.0 {
|
||||
past_center = true;
|
||||
} else if past_center {
|
||||
after.push(o);
|
||||
} else {
|
||||
before.push(o);
|
||||
}
|
||||
}
|
||||
(before, after)
|
||||
}
|
||||
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
|
||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
|
||||
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
|
||||
@@ -744,6 +776,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_paint_order_respects_overlap() {
|
||||
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
def.copies = 3;
|
||||
assert!(close(*def.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(-10.0, -10.0)));
|
||||
let mut d2 = def.clone();
|
||||
d2.overlap = Overlap { right_in_front: true, bottom_in_front: true };
|
||||
assert!(close(*d2.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(10.0, 10.0)));
|
||||
assert_eq!(def.preview_offsets_in_paint_order().len(), 9);
|
||||
let (before, after) = def.preview_offsets_dimmed_sides();
|
||||
assert_eq!(before.len() + after.len(), 8);
|
||||
assert!(before.iter().all(|o| *o != Vec2::ZERO) && after.iter().all(|o| *o != Vec2::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covering_offsets_and_overlap_order() {
|
||||
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]).
|
||||
//! Placed documents: another VectorCraft file, or a page of a PDF or `.ai` file, placed linked
|
||||
//! ([`PlacedDocument`]).
|
||||
//!
|
||||
//! The object keeps the link to the file and the file's bytes, stored like image data in
|
||||
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
|
||||
@@ -6,8 +7,9 @@
|
||||
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
|
||||
//! expand it like a live object ([`crate::live::expanded`]).
|
||||
//!
|
||||
//! The file is read by the native format's loader, which lives above this crate and registers
|
||||
//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting,
|
||||
//! The file is read by the engine's loader (native documents, PDF and `.ai` pages), which lives
|
||||
//! above this crate and registers itself ([`set_loader`]); the art read is kept in a small cache
|
||||
//! by content. Before exporting,
|
||||
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
|
||||
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
|
||||
//! of the document, so they never meet the user's or show in panels. Placed documents inside a
|
||||
@@ -25,16 +27,21 @@ use vectorcraft_color::Paint;
|
||||
|
||||
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
|
||||
|
||||
/// A placed document: an artboard of another VectorCraft file, shown as one locked object.
|
||||
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` file,
|
||||
/// shown as one locked object.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlacedDocument {
|
||||
/// The file (its `page` is the artboard shown, 1-based).
|
||||
/// The file (its `page` is the artboard or page shown, 1-based).
|
||||
pub link: LinkInfo,
|
||||
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
|
||||
pub key: String,
|
||||
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub bounding: bool,
|
||||
/// The box of a PDF or `.ai` page shown when not `bounding` (File › Place's `crop`); a
|
||||
/// VectorCraft document shows its artboard whatever this says.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub page_box: PageBox,
|
||||
/// The artboard's size (or the art's) at 100% (pt).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
@@ -45,11 +52,43 @@ pub struct PlacedDocument {
|
||||
pub placement: PlacementOptions,
|
||||
}
|
||||
|
||||
/// The box of a PDF page a placed PDF or `.ai` file shows (File › Place's `crop`, other than
|
||||
/// `bounding`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PageBox {
|
||||
/// The visible page area.
|
||||
#[default]
|
||||
Crop,
|
||||
Art,
|
||||
Trim,
|
||||
Bleed,
|
||||
Media,
|
||||
}
|
||||
|
||||
impl PageBox {
|
||||
/// The `crop` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PageBox::Crop => "crop",
|
||||
PageBox::Art => "art",
|
||||
PageBox::Trim => "trim",
|
||||
PageBox::Bleed => "bleed",
|
||||
PageBox::Media => "media",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
|
||||
/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and
|
||||
/// its preview.
|
||||
pub fn key_for(hash: &str, page: u32, bounding: bool) -> String {
|
||||
format!("{hash}-{page}{}", if bounding { "b" } else { "" })
|
||||
/// artboard or page `page` (`bounding`: its art's bounds; else the page's box `page_box`):
|
||||
/// objects showing the same thing share a blob and its preview.
|
||||
pub fn key_for(hash: &str, page: u32, bounding: bool, page_box: PageBox) -> String {
|
||||
let shown = match (bounding, page_box) {
|
||||
(true, _) => "b",
|
||||
(false, PageBox::Crop) => "",
|
||||
(false, b) => b.id(),
|
||||
};
|
||||
format!("{hash}-{page}{shown}")
|
||||
}
|
||||
|
||||
impl PlacedDocument {
|
||||
@@ -576,6 +615,7 @@ mod tests {
|
||||
link: LinkInfo::new("/art/logo.vectorcraft"),
|
||||
key: key.into(),
|
||||
bounding: false,
|
||||
page_box: PageBox::Crop,
|
||||
width: 80.0,
|
||||
height: 40.0,
|
||||
xf: Affine::translate((10.0, 10.0)),
|
||||
@@ -616,9 +656,18 @@ mod tests {
|
||||
let v = serde_json::to_value(&n).unwrap();
|
||||
assert_eq!(v["kind"]["type"], "placeddocument");
|
||||
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
|
||||
// The box of a PDF page is written only when it isn't the crop box.
|
||||
assert!(v["kind"].get("page_box").is_none(), "{v}");
|
||||
let back: Node = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back, n);
|
||||
assert_eq!(key_for("img00", 2, true), "img00-2b");
|
||||
assert_eq!(key_for("img00", 2, true, PageBox::Crop), "img00-2b");
|
||||
assert_eq!(key_for("img00", 2, false, PageBox::Crop), "img00-2");
|
||||
assert_eq!(key_for("img00", 2, false, PageBox::Trim), "img00-2trim");
|
||||
let mut trim = placed("doc1-1trim");
|
||||
trim.page_box = PageBox::Trim;
|
||||
let v = serde_json::to_value(&trim).unwrap();
|
||||
assert_eq!(v["page_box"], "trim");
|
||||
assert_eq!(serde_json::from_value::<PlacedDocument>(v).unwrap(), trim);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! What plug-ins keep with a document: settings that belong to the whole document rather than to
|
||||
//! one object, such as the pens a plotter holds or a machine's profile.
|
||||
//!
|
||||
//! They are the document's foreign data ([`Document::unknown`], under [`PLUGIN_DATA`]), as the
|
||||
//! perspective grid and the brushes are, so the native file has no section of its own for them
|
||||
//! and a version from before them keeps them when it saves. The host never reads them: a filter
|
||||
//! plug-in writes its own and reads only its own back, and an exporter, which is given the whole
|
||||
//! document, can read them all (`vectorcraft-plugins`, which caps what one plug-in keeps).
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// `Document::unknown` key: `{"<plug-in id>": <what it keeps>}`.
|
||||
pub const PLUGIN_DATA: &str = "pluginData";
|
||||
|
||||
impl Document {
|
||||
/// What plug-in `id` keeps with the document, if anything.
|
||||
pub fn plugin_data(&self, id: &str) -> Option<&Value> {
|
||||
self.unknown.get(PLUGIN_DATA).and_then(|m| m.get(id))
|
||||
}
|
||||
|
||||
/// Keeps `data` for plug-in `id` in place of what it kept; `None` forgets it, and the key goes
|
||||
/// once no plug-in keeps anything. A value under the key that isn't a map of plug-in ids (a
|
||||
/// damaged file) is replaced when a plug-in keeps something.
|
||||
pub fn set_plugin_data(&mut self, id: &str, data: Option<Value>) {
|
||||
let Some(data) = data else {
|
||||
// Nothing to forget: don't copy the foreign data the undo history shares.
|
||||
if self.plugin_data(id).is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(m) = self.unknown.get_mut(PLUGIN_DATA).and_then(Value::as_object_mut) else { return };
|
||||
m.remove(id);
|
||||
if m.is_empty() {
|
||||
self.unknown.remove(PLUGIN_DATA);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let kept = self.unknown.entry(PLUGIN_DATA.to_string()).or_insert_with(|| Value::Object(Map::new()));
|
||||
if !kept.is_object() {
|
||||
*kept = Value::Object(Map::new());
|
||||
}
|
||||
if let Some(m) = kept.as_object_mut() {
|
||||
m.insert(id.to_string(), data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Each plug-in has its own entry, replaced when it keeps something else; forgetting the last
|
||||
/// one removes the key, and forgetting what isn't kept copies nothing.
|
||||
#[test]
|
||||
fn each_plug_in_keeps_its_own_data() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
assert_eq!(d.plugin_data("a"), None);
|
||||
d.set_plugin_data("a", Some(json!({"pens": 2})));
|
||||
d.set_plugin_data("b", Some(json!([1, 2])));
|
||||
d.set_plugin_data("a", Some(json!({"pens": 3})));
|
||||
assert_eq!((d.plugin_data("a"), d.plugin_data("b")), (Some(&json!({"pens": 3})), Some(&json!([1, 2]))));
|
||||
d.set_plugin_data("a", None);
|
||||
assert_eq!(d.unknown[PLUGIN_DATA], json!({"b": [1, 2]}));
|
||||
let before = d.clone();
|
||||
d.set_plugin_data("a", None);
|
||||
assert!(crate::Shared::ptr_eq(&before.unknown, &d.unknown), "still shared with the earlier version");
|
||||
d.set_plugin_data("b", None);
|
||||
assert!(!d.unknown.contains_key(PLUGIN_DATA));
|
||||
}
|
||||
|
||||
/// A damaged file's value under the key holds nothing for any plug-in, stays while there is
|
||||
/// nothing to forget, and is replaced when a plug-in keeps something.
|
||||
#[test]
|
||||
fn a_damaged_value_holds_nothing() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.unknown.insert(PLUGIN_DATA.into(), json!(5));
|
||||
assert_eq!(d.plugin_data("a"), None);
|
||||
d.set_plugin_data("a", None);
|
||||
assert_eq!(d.unknown[PLUGIN_DATA], json!(5));
|
||||
d.set_plugin_data("a", Some(json!(true)));
|
||||
assert_eq!(d.unknown[PLUGIN_DATA], json!({"a": true}));
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[
|
||||
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
||||
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
||||
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
|
||||
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
];
|
||||
|
||||
@@ -189,7 +189,7 @@ fn fx(id: &str, params: Value) -> Effect {
|
||||
|
||||
/// A gradient of `(offset, colour, opacity)` stops.
|
||||
fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient {
|
||||
Gradient { kind, stops: stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect() }
|
||||
Gradient::new(kind, stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect())
|
||||
}
|
||||
|
||||
/// A linear gradient fitted to each object's box at `angle` degrees (90: upwards).
|
||||
|
||||
@@ -23,6 +23,14 @@ fn every_unit_round_trips_through_its_field_text() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_leading_plus_is_accepted() {
|
||||
assert_eq!(Unit::Points.parse("+5"), Some(5.0));
|
||||
assert_eq!(Unit::Millimeters.parse("+5mm"), Some(Unit::Millimeters.to_pt(5.0)));
|
||||
assert_eq!(Unit::Points.parse("+5+3"), Some(8.0));
|
||||
assert_eq!(Unit::Points.parse("5+-3"), Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_units_win_over_the_fields_unit() {
|
||||
let mm = 72.0 / 25.4;
|
||||
|
||||
+40
-1
@@ -104,6 +104,12 @@ pub struct CharStyle {
|
||||
/// their line line up with it.
|
||||
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub char_align: CharAlign,
|
||||
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
|
||||
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
|
||||
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
|
||||
/// them.
|
||||
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
|
||||
pub char_direction: Option<ParaDirection>,
|
||||
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
|
||||
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
|
||||
/// its proportional heights, `vpal`.
|
||||
@@ -297,6 +303,7 @@ impl Default for CharStyle {
|
||||
stroke_align: StrokeAlign::Center,
|
||||
position: CharPosition::Normal,
|
||||
char_align: CharAlign::RomanBaseline,
|
||||
char_direction: None,
|
||||
proportional_metrics: false,
|
||||
tate_chu_yoko: false,
|
||||
small_caps: None,
|
||||
@@ -862,7 +869,8 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's base direction ([`ParaStyle::direction`]).
|
||||
/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
|
||||
/// in ([`CharStyle::char_direction`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParaDirection {
|
||||
@@ -1702,6 +1710,21 @@ impl TextObject {
|
||||
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
|
||||
}
|
||||
}
|
||||
/// How far the characters' strokes paint past their glyphs, in document space: they scale
|
||||
/// with the type.
|
||||
pub fn char_stroke_outset(&self) -> f64 {
|
||||
let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0);
|
||||
self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k
|
||||
}
|
||||
/// How far the characters' strokes can paint past their glyphs, their miter spikes and square
|
||||
/// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must
|
||||
/// redraw. They stretch with the type, so this is as far as its transform takes them on either
|
||||
/// axis. The visual bounds take [`Self::char_stroke_outset`].
|
||||
pub fn char_stroke_reach(&self) -> f64 {
|
||||
let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max);
|
||||
let [a, b, c, d, ..] = self.xf.as_coeffs();
|
||||
reach * (a.abs() + c.abs()).max(b.abs() + d.abs())
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
@@ -1906,6 +1929,22 @@ impl crate::Document {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Character Direction is saved only when set, and styles written before it existed read as
|
||||
/// Default.
|
||||
#[test]
|
||||
fn character_direction_is_saved_only_when_set() {
|
||||
let st = CharStyle::default();
|
||||
assert_eq!(st.char_direction, None);
|
||||
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
|
||||
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
|
||||
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
|
||||
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
|
||||
}
|
||||
let old = serde_json::to_value(&st).unwrap();
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
|
||||
}
|
||||
|
||||
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
|
||||
#[test]
|
||||
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
|
||||
|
||||
@@ -122,9 +122,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let m = reshape(n, symbol.as_deref())?;
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own, so
|
||||
// its gradient fits it rather than spanning every copy.
|
||||
if crate::has_gradient_copies(n)
|
||||
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
|
||||
// patterns: each copy an object of its own, so its gradient fits it rather than spanning
|
||||
// every copy and its strokes and patterns can differ from the others'.
|
||||
if crate::has_transform_art(n)
|
||||
&& let Some(m) = reshape(n, None)
|
||||
{
|
||||
return Some(bake_pieces(d, m));
|
||||
@@ -565,7 +566,7 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
}
|
||||
None => {
|
||||
let effects = st.effects.clone();
|
||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) };
|
||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.stroke_as_fill()) };
|
||||
piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill))
|
||||
}
|
||||
}
|
||||
@@ -673,6 +674,22 @@ mod tests {
|
||||
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_stroke_leaves_dither_behind() {
|
||||
// Strokes ignore dither, so the filled outline goes without it and looks as the stroke did.
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
|
||||
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
|
||||
g.gradient.dither = true;
|
||||
s.paint = Paint::Gradient(Box::new(g));
|
||||
}
|
||||
let m = bake_appearance(&n).unwrap();
|
||||
let ch = m.children().unwrap();
|
||||
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
|
||||
let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") };
|
||||
assert!(!g.gradient.dither);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
@@ -1034,7 +1051,7 @@ mod tests {
|
||||
fn gradient_copies(id: u64) -> Node {
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
|
||||
n
|
||||
|
||||
@@ -10,8 +10,9 @@ fn diag(b: Rect) -> f64 {
|
||||
b.width().hypot(b.height()).max(1e-6)
|
||||
}
|
||||
|
||||
/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates).
|
||||
pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates),
|
||||
/// making at most `max` anchors ([`map_nonlinear_within`]).
|
||||
pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)];
|
||||
if let Some(arr) = p.get("corners").and_then(Value::as_array) {
|
||||
for (i, v) in arr.iter().take(4).enumerate() {
|
||||
@@ -34,7 +35,7 @@ pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let bot = c[3].lerp(c[2], u);
|
||||
top.lerp(bot, v)
|
||||
};
|
||||
map_nonlinear(path, diag(b) / 16.0, f)
|
||||
map_nonlinear_within(path, diag(b) / 16.0, max, f)
|
||||
}
|
||||
|
||||
/// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the
|
||||
@@ -67,21 +68,29 @@ pub fn pucker_bloat(path: &PathData, b: Rect, amount: f64) -> PathData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`.
|
||||
pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
/// Most points Roughen resamples one segment into.
|
||||
const MAX_ROUGHEN_POINTS: usize = 2000;
|
||||
|
||||
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where
|
||||
/// that would make more than `max` anchors, the points are spaced further apart until it doesn't
|
||||
/// (stacked Roughens multiply their points, #3578).
|
||||
pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let size = num(p, "size", 5.0).clamp(0.0, 1e4);
|
||||
let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size };
|
||||
let detail = num(p, "detail", 10.0).clamp(0.0, 100.0);
|
||||
let spacing = if detail > 0.0 { 72.0 / detail } else { f64::INFINITY };
|
||||
// A segment of k points adds k - 1 anchors to the path.
|
||||
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect();
|
||||
let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count()));
|
||||
let mut lens = lens.iter();
|
||||
let smooth = smooth_points(p);
|
||||
let seed = seed(p);
|
||||
let mut subs = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let mut pts = vec![];
|
||||
for i in 0..sp.segment_count() {
|
||||
for (i, &len) in (0..sp.segment_count()).zip(&mut lens) {
|
||||
let c = sp.segment(i);
|
||||
let len = vectorcraft_geom::kurbo::ParamCurveArclen::arclen(&c, 0.1);
|
||||
let k = if spacing.is_finite() { ((len / spacing).ceil() as usize).clamp(1, 2000) } else { 1 };
|
||||
let k = pieces(len, spacing, MAX_ROUGHEN_POINTS);
|
||||
for j in 0..k {
|
||||
pts.push(c.eval(j as f64 / k as f64));
|
||||
}
|
||||
@@ -105,11 +114,16 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
|
||||
/// The move and the number of copies of a Transform effect with params `p` that only moves what
|
||||
/// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects,
|
||||
/// or moves by a random share.
|
||||
/// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales
|
||||
/// strokes while leaving the geometry (Scale Strokes & Effects with Transform Objects off).
|
||||
pub(crate) fn transform_move(p: &Value) -> Option<(Vec2, usize)> {
|
||||
let (m, copies) = transform_map(Rect::ZERO, p, 0);
|
||||
let [a, b, c, d, x, y] = m.as_coeffs();
|
||||
(!flag(p, "random", false) && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
|
||||
// Transform Patterns moves the patterns along; with Transform Objects off, Scale Strokes &
|
||||
// Effects still scales the strokes by the map the geometry doesn't take.
|
||||
let o = TransformOptions::of(p);
|
||||
let plain = !flag(p, "random", false) && !o.patterns && (o.objects || !o.strokes);
|
||||
(plain && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies))
|
||||
}
|
||||
|
||||
/// The offsets at which Transform effects with params `ps`, applied in order, place `path`, the
|
||||
@@ -161,7 +175,9 @@ pub(crate) fn within_reach(r: Rect) -> bool {
|
||||
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
|
||||
/// `random`, each scale goes a random share of the way from 100 % to its value and each move and
|
||||
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
|
||||
/// on every redraw while each object varies its own way.
|
||||
/// on every redraw while each object varies its own way. With Transform Objects off the path stays
|
||||
/// as it is; Transform Patterns and Scale Strokes & Effects reach the appearance through the
|
||||
/// object's art ([`crate::has_transform_art`]).
|
||||
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
transform_within(path, b, p, seed, MAX_COPY_ANCHORS)
|
||||
}
|
||||
@@ -192,12 +208,49 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m
|
||||
out
|
||||
}
|
||||
|
||||
/// The Transform effect's options beyond its geometry (#885).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct TransformOptions {
|
||||
/// Transform Objects (default on): the geometry transforms and is copied. Off, it stays where
|
||||
/// it is and only the two options below apply, once.
|
||||
pub objects: bool,
|
||||
/// Transform Patterns (default off): pattern fills and strokes transform along.
|
||||
pub patterns: bool,
|
||||
/// Scale Strokes & Effects (default off): stroke weights and dashes, and the distances of the
|
||||
/// effects on the fills and strokes, scale by the map's mean scale (`√|det|`).
|
||||
pub strokes: bool,
|
||||
}
|
||||
|
||||
impl TransformOptions {
|
||||
/// The options of a Transform effect with params `p`. Missing keys take their defaults, so
|
||||
/// effects saved before the options existed draw as they did.
|
||||
pub fn of(p: &Value) -> Self {
|
||||
Self { objects: flag(p, "objects", true), patterns: flag(p, "patterns", false), strokes: flag(p, "strokes", false) }
|
||||
}
|
||||
|
||||
/// Do they reach past the geometry (Transform Patterns or Scale Strokes & Effects)?
|
||||
pub fn reach_appearance(self) -> bool {
|
||||
self.patterns || self.strokes
|
||||
}
|
||||
}
|
||||
|
||||
/// The Transform effect's map and how many copies of what it is applied to it makes, for bounds
|
||||
/// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map
|
||||
/// applies to the object itself when `copies` is 0, and once more to each of `copies` copies
|
||||
/// otherwise (see [`transform_within`], and images, whose copies count nodes against
|
||||
/// [`MAX_COPY_OBJECTS`]).
|
||||
/// [`MAX_COPY_OBJECTS`]). With Transform Objects off the geometry stays: the identity and no
|
||||
/// copies ([`transform_affine`] is the map its other options still use).
|
||||
pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
|
||||
if !TransformOptions::of(p).objects {
|
||||
return (Affine::IDENTITY, 0);
|
||||
}
|
||||
(transform_affine(b, p, seed), num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize)
|
||||
}
|
||||
|
||||
/// The one transformation of the Transform effect with params `p` on an object of bounds `b`
|
||||
/// (the reference point lies on them; `seed` for Random), whatever Transform Objects says. Each
|
||||
/// copy applies it once more to the last.
|
||||
pub(crate) fn transform_affine(b: Rect, p: &Value, seed: u64) -> Affine {
|
||||
let random = flag(p, "random", false);
|
||||
// The share (0..1) of value `k` applied.
|
||||
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
|
||||
@@ -206,12 +259,10 @@ pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) {
|
||||
let reflect = |key: &str| if flag(p, key, false) { -1.0 } else { 1.0 };
|
||||
let o = vectorcraft_geom::reference_point(b, num(p, "reference", 4.0).clamp(0.0, 8.0) as usize).to_vec2();
|
||||
// Reflect X flips the x coordinates (left to right), Reflect Y the y coordinates.
|
||||
let m = Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o)
|
||||
Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o)
|
||||
* Affine::rotate(-(num(p, "rotate", 0.0) * share(4)).to_radians())
|
||||
* Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY"))
|
||||
* Affine::translate(-o);
|
||||
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
|
||||
(m, copies)
|
||||
* Affine::translate(-o)
|
||||
}
|
||||
|
||||
/// Tweak: random displacement of anchors and/or control points.
|
||||
@@ -246,8 +297,9 @@ pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
out
|
||||
}
|
||||
|
||||
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle.
|
||||
pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle,
|
||||
/// making at most `max` anchors ([`map_nonlinear_within`]).
|
||||
pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
|
||||
let ang = angle.clamp(-3600.0, 3600.0).to_radians();
|
||||
if ang == 0.0 {
|
||||
return path.clone();
|
||||
@@ -261,8 +313,8 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
let (s, co) = a.sin_cos();
|
||||
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
|
||||
};
|
||||
let pieces = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0));
|
||||
map_nonlinear(path, pieces, f)
|
||||
let piece = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0));
|
||||
map_nonlinear_within(path, piece, max, f)
|
||||
}
|
||||
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
@@ -280,11 +332,17 @@ struct Ridge {
|
||||
/// displaced along the normal. Corner points join them with straight lines. Smooth points make a
|
||||
/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the
|
||||
/// way to the next ridge, so each span between them follows a half cosine (an S through the path)
|
||||
/// whatever the size of the ridges (#2343).
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
/// whatever the size of the ridges (#2343). Where `ridges` would make more than `max` anchors,
|
||||
/// each segment gets as many as fit (stacked Zig Zags multiply their points, #3578).
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
let ridges = num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize;
|
||||
// Each segment makes `ridges + 1` points, and an open path's end one more (all its anchors
|
||||
// when it has no segment). With no ridges, that is at most the path's own anchors.
|
||||
let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add);
|
||||
let ends: usize = path.subpaths.iter().filter(|sp| !sp.closed).map(|sp| if sp.segment_count() > 0 { 1 } else { sp.anchors.len() }).sum();
|
||||
let fit = (max.saturating_sub(ends) / segs.max(1)).saturating_sub(1);
|
||||
let ridges = (num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize).min(fit);
|
||||
let smooth = smooth_points(p);
|
||||
let mut subs = vec![];
|
||||
for sp in &path.subpaths {
|
||||
|
||||
@@ -589,7 +589,7 @@ mod tests {
|
||||
}
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
// The type's characters fit the gradient to its layout; the square has it placed across itself.
|
||||
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
|
||||
@@ -615,7 +615,7 @@ mod tests {
|
||||
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
|
||||
for (k, paint) in paints.into_iter().enumerate() {
|
||||
|
||||
+124
-12
@@ -58,6 +58,8 @@ mod warp;
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_transformfx;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
@@ -78,7 +80,10 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use reshape::{
|
||||
expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art,
|
||||
outline_text, reshape,
|
||||
};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
|
||||
@@ -281,8 +286,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.transform",
|
||||
"Transform…",
|
||||
DT,
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw)}",
|
||||
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false}),
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
|
||||
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
|
||||
),
|
||||
g(
|
||||
"distort.tweak",
|
||||
@@ -598,6 +603,21 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"detail": 4.0, "smoothness": 4.0}),
|
||||
),
|
||||
r(
|
||||
"sketch.conteCrayon",
|
||||
"Conté Crayon…",
|
||||
SKETCH,
|
||||
"{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.",
|
||||
json!({
|
||||
"foregroundLevel": 12.0,
|
||||
"backgroundLevel": 10.0,
|
||||
"texture": "canvas",
|
||||
"scaling": 100.0,
|
||||
"relief": 4.0,
|
||||
"lightDirection": "top",
|
||||
"invert": false
|
||||
}),
|
||||
),
|
||||
r(
|
||||
"sketch.graphicPen",
|
||||
"Graphic Pen…",
|
||||
@@ -1068,15 +1088,80 @@ impl<'a> GeomContext<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most anchors a chain of geometry effects makes of one path, or of all the paths of a type,
|
||||
/// symbol or image art together; a path that already has more keeps its own count as the limit.
|
||||
/// Roughen resamples each segment into up to 2000 points, Zig Zag into up to 101 and the warps
|
||||
/// into up to 64, so stacked effects multiply: three Roughens of a rectangle needed tens of
|
||||
/// gigabytes (#3578). Each effect builds within it, and one whose result would still pass it is
|
||||
/// left out.
|
||||
pub(crate) const MAX_EFFECT_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// Most lines the renderer is estimated to draw a chain of geometry effects' result with at
|
||||
/// 1 px/pt ([`util::line_estimate`]), or the path's own estimate when that is more. A large
|
||||
/// Roughen or Zig Zag makes segments thousands of points long, each drawn as up to hundreds of
|
||||
/// lines: two million anchors of them came to 141 million lines, 7 GB in the renderer and past
|
||||
/// the 2²⁷ lines vello takes of one path (#3578). Art of small curves draws its anchors as a few
|
||||
/// lines each, so this rarely binds before [`MAX_EFFECT_ANCHORS`] does.
|
||||
pub(crate) const MAX_EFFECT_LINES: usize = 8_000_000;
|
||||
|
||||
/// What geometry effects may make of a path, or what a path costs: its anchors and the lines the
|
||||
/// renderer is estimated to draw it with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Budget {
|
||||
pub anchors: usize,
|
||||
pub lines: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// The budget of a chain of geometry effects.
|
||||
pub(crate) const EFFECTS: Self = Self { anchors: MAX_EFFECT_ANCHORS, lines: MAX_EFFECT_LINES };
|
||||
|
||||
/// What `path` costs.
|
||||
pub(crate) fn of(path: &PathData) -> Self {
|
||||
Self { anchors: path.anchor_count(), lines: util::line_estimate(path) }
|
||||
}
|
||||
|
||||
/// This budget, raised to `cost` where that is more.
|
||||
pub(crate) fn at_least(self, cost: Self) -> Self {
|
||||
Self { anchors: self.anchors.max(cost.anchors), lines: self.lines.max(cost.lines) }
|
||||
}
|
||||
|
||||
/// Does `cost` fit this budget?
|
||||
pub(crate) fn fits(self, cost: Self) -> bool {
|
||||
cost.anchors <= self.anchors && cost.lines <= self.lines
|
||||
}
|
||||
|
||||
/// What is left of this budget once `cost` is spent.
|
||||
pub(crate) fn minus(self, cost: Self) -> Self {
|
||||
Self { anchors: self.anchors.saturating_sub(cost.anchors), lines: self.lines.saturating_sub(cost.lines) }
|
||||
}
|
||||
|
||||
/// This cost and `cost` together.
|
||||
pub(crate) fn plus(self, cost: Self) -> Self {
|
||||
Self { anchors: self.anchors.saturating_add(cost.anchors), lines: self.lines.saturating_add(cost.lines) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the
|
||||
/// reference box of the first effect (usually the object's geometric bounds); each later effect
|
||||
/// uses the bounds of its input. Raster and unknown effects are skipped.
|
||||
/// uses the bounds of its input. Raster and unknown effects are skipped, and so is an effect
|
||||
/// whose result would pass 2 million anchors or 8 million lines drawn (or the path's own counts,
|
||||
/// when they are more).
|
||||
pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData {
|
||||
apply_geometry_with(effects, path, bounds, &GeomContext::default())
|
||||
}
|
||||
|
||||
/// [`apply_geometry`] with an explicit [`GeomContext`].
|
||||
pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS)
|
||||
}
|
||||
|
||||
/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that
|
||||
/// is more.
|
||||
pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData {
|
||||
// `budget` raised to what `path` costs, measured once, and only for a path that has geometry
|
||||
// effects.
|
||||
let mut limit = None;
|
||||
let mut cur = path.clone();
|
||||
let mut b = bounds;
|
||||
let mut first = true;
|
||||
@@ -1089,7 +1174,10 @@ pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ct
|
||||
break;
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
cur = apply_one(&e.id, &p, &cur, b, ctx);
|
||||
let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path)));
|
||||
if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) {
|
||||
cur = next;
|
||||
}
|
||||
}
|
||||
cur
|
||||
}
|
||||
@@ -1099,20 +1187,44 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
||||
apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
/// Geometry effect `id` with params `p` on `path`, whose reference box is `b`; `None` when its
|
||||
/// result would not fit `budget` (the effects that multiply anchors build within its anchors).
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, budget: Budget) -> Option<PathData> {
|
||||
let out = apply_unchecked(id, p, path, b, ctx, budget.anchors);
|
||||
let cost = Budget::of(&out);
|
||||
if budget.fits(cost) {
|
||||
return Some(out);
|
||||
}
|
||||
if cost.lines <= budget.lines || !builds_within(id) {
|
||||
return None;
|
||||
}
|
||||
// Anchors drawn as many lines each (a large Roughen or Zig Zag): once more with as many fewer
|
||||
// anchors as there were lines too many, and a tenth to spare, as segments vary in their lines.
|
||||
let fewer = (cost.anchors as f64 * 0.9 * (budget.lines as f64 / cost.lines as f64)) as usize;
|
||||
let out = apply_unchecked(id, p, path, b, ctx, fewer.min(budget.anchors));
|
||||
budget.fits(Budget::of(&out)).then_some(out)
|
||||
}
|
||||
|
||||
/// Does geometry effect `id` build within the anchors it is given?
|
||||
fn builds_within(id: &str) -> bool {
|
||||
matches!(id, "distort.freeDistort" | "distort.roughen" | "distort.transform" | "distort.twist" | "distort.zigZag") || id.starts_with("warp.")
|
||||
}
|
||||
|
||||
/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it.
|
||||
fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData {
|
||||
use util::*;
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p, max),
|
||||
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
|
||||
"distort.roughen" => distort::roughen(path, b, p),
|
||||
"distort.transform" => distort::transform(path, b, p, ctx.seed),
|
||||
"distort.roughen" => distort::roughen(path, b, p, max),
|
||||
"distort.transform" => distort::transform_within(path, b, p, ctx.seed, max),
|
||||
"distort.tweak" => distort::tweak(path, b, p),
|
||||
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)),
|
||||
"distort.zigZag" => distort::zig_zag(path, b, p),
|
||||
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0), max),
|
||||
"distort.zigZag" => distort::zig_zag(path, b, p, max),
|
||||
"path.offsetPath" => {
|
||||
let off = num(p, "offset", 10.0).clamp(-1e5, 1e5);
|
||||
if off.abs() < 1e-9 {
|
||||
@@ -1145,7 +1257,7 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo
|
||||
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),
|
||||
"stylize.scribble" => stylize::scribble(path, b, p),
|
||||
_ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) {
|
||||
Some(style) => warp::warp(path, b, style, p),
|
||||
Some(style) => warp::warp(path, b, style, p, max),
|
||||
None => path.clone(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
|
||||
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
|
||||
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let mut p = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(p))
|
||||
}
|
||||
@@ -334,7 +334,7 @@ mod tests {
|
||||
stop.opacity = 0.4;
|
||||
stop.midpoint = 0.3;
|
||||
let g = GradientPaint {
|
||||
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
|
||||
gradient: Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, Color::BLACK)]),
|
||||
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
|
||||
angle: 0.0,
|
||||
swatch: None,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
|
||||
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
|
||||
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
|
||||
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster,
|
||||
//! Reticulation, Stamp and Torn Edges, Stylize ›
|
||||
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern,
|
||||
//! Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper, Stylize ›
|
||||
//! Glowing Edges, Texture
|
||||
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
|
||||
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
|
||||
@@ -44,7 +44,7 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 55] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 56] = [
|
||||
"artistic.coloredPencil",
|
||||
"artistic.cutout",
|
||||
"artistic.dryBrush",
|
||||
@@ -82,6 +82,7 @@ pub const PIXEL_EFFECTS: [&str; 55] = [
|
||||
"sketch.chalkCharcoal",
|
||||
"sketch.charcoal",
|
||||
"sketch.chrome",
|
||||
"sketch.conteCrayon",
|
||||
"sketch.graphicPen",
|
||||
"sketch.halftonePattern",
|
||||
"sketch.notePaper",
|
||||
@@ -210,6 +211,11 @@ pub enum PixelFx {
|
||||
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
|
||||
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
|
||||
Chrome { detail: f64, smoothness: f64 },
|
||||
/// Sketch › Conté Crayon: dark areas in a black crayon, light areas in a white one, on mid-grey
|
||||
/// paper; `foreground` and `background` (0..20) set how far each crayon reaches into the
|
||||
/// midtones, and the paper is a `texture` surface scaled by `scaling` (0.5..2), in relief as
|
||||
/// strong as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
|
||||
ConteCrayon { foreground: f64, background: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
|
||||
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
|
||||
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
|
||||
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
|
||||
@@ -483,6 +489,15 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
|
||||
},
|
||||
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
|
||||
"sketch.conteCrayon" => PixelFx::ConteCrayon {
|
||||
foreground: num(p, "foregroundLevel", 12.0).clamp(0.0, 20.0),
|
||||
background: num(p, "backgroundLevel", 10.0).clamp(0.0, 20.0),
|
||||
texture: Texture::parse(text(p, "texture", "canvas")),
|
||||
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
|
||||
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
|
||||
light: Light::parse(text(p, "lightDirection", "top")),
|
||||
invert: flag(p, "invert", false),
|
||||
},
|
||||
"sketch.graphicPen" => PixelFx::GraphicPen {
|
||||
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
|
||||
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
|
||||
@@ -617,6 +632,7 @@ impl PixelFx {
|
||||
PixelFx::UnsharpMask { .. }
|
||||
| PixelFx::ChalkCharcoal { .. }
|
||||
| PixelFx::Chrome { .. }
|
||||
| PixelFx::ConteCrayon { .. }
|
||||
| PixelFx::HalftonePattern { .. }
|
||||
| PixelFx::NotePaper { .. }
|
||||
| PixelFx::Plaster { .. }
|
||||
@@ -692,6 +708,7 @@ impl PixelFx {
|
||||
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
|
||||
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
|
||||
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
|
||||
PixelFx::ConteCrayon { .. } => Some(sketch::CONTE_REACH),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::HalftonePattern { .. }
|
||||
@@ -778,6 +795,9 @@ impl PixelFx {
|
||||
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
|
||||
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
|
||||
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
|
||||
PixelFx::ConteCrayon { foreground, background, ref texture, scaling, relief, light, invert } => {
|
||||
sketch::conte_crayon(px, w, h, space, foreground, background, texture, scaling, relief, light, invert)
|
||||
}
|
||||
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
|
||||
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
|
||||
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Halftone Pattern, Note Paper, Photocopy, Plaster,
|
||||
//! Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in
|
||||
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Halftone Pattern, Note Paper, Photocopy,
|
||||
//! Plaster, Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in
|
||||
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
|
||||
//! black and white; Water Paper flows colours on damp fibrous paper) and the public Photoshop Elements Artistic filters guide. Visual
|
||||
//! approximations, not proprietary reproductions.
|
||||
//! black and white; Water Paper flows colours on damp fibrous paper; Conté Crayon draws dark areas
|
||||
//! in black and light areas in white) and the public Photoshop Elements Sketch filters guide
|
||||
//! (<https://helpx.adobe.com/photoshop-elements/using/sketch-filters.html>: Conté Crayon's
|
||||
//! foreground and background levels and texture options). Visual approximations, not proprietary
|
||||
//! reproductions.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
|
||||
|
||||
@@ -12,11 +15,12 @@ use vectorcraft_geom::{Point, Vec2};
|
||||
use super::distort::displace;
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand, value_noise};
|
||||
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight};
|
||||
use super::texture::{Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer};
|
||||
use super::{PixelSpace, blur_plane, pick};
|
||||
|
||||
const SALT_CHALK: u64 = 216;
|
||||
const SALT_CHROME: u64 = 0xC40_4D45;
|
||||
const SALT_CONTE: u64 = 0xC047E;
|
||||
const SALT_TORN: u64 = 0x746f726e5f6564;
|
||||
const SALT_WATER: u64 = 208;
|
||||
const EDGE_SOFT: f64 = 0.8;
|
||||
@@ -362,6 +366,78 @@ pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
|
||||
});
|
||||
}
|
||||
|
||||
/// How far (document units) Conté Crayon samples the softened tones that choose its crayons.
|
||||
pub(super) const CONTE_REACH: f64 = 3.0 * TONE_SOFT + 1.0;
|
||||
|
||||
/// How much of a crayon is laid down where `amount` (0..1: how dark, for the dark crayon, or how
|
||||
/// light, for the light one) meets its `level` (0..1) and `grain` (0..1, the paper's tooth and the
|
||||
/// stroke): at level 0 only the deepest tones take the crayon, at level 1 it reaches past the
|
||||
/// middle tone; the grain breaks its edge.
|
||||
fn crayon(amount: f64, level: f64, grain: f64) -> f64 {
|
||||
ramp((amount - (0.9 - 0.45 * level) + 0.4 * (grain - 0.5)) / 0.12 + 0.5)
|
||||
}
|
||||
|
||||
/// Conté Crayon: the dark areas drawn in a dense black crayon and the light areas in a pure white
|
||||
/// one, on mid-grey paper whose tooth (`texture` scaled by `scaling` (0.5..2), its heights turned
|
||||
/// over when `invert`) and diagonal strokes break the crayons. `foreground` and `background`
|
||||
/// (0..20) set how far the black and the white crayon reach into the midtones; the paper then
|
||||
/// stands in relief as strong as `relief` (0..50), lit from `light`, as Texturizer draws it.
|
||||
/// Coverage is kept.
|
||||
#[allow(clippy::too_many_arguments)] // the effect's seven options
|
||||
pub(super) fn conte_crayon(
|
||||
px: &mut [[u8; 4]],
|
||||
w: usize,
|
||||
h: usize,
|
||||
space: &PixelSpace,
|
||||
foreground: f64,
|
||||
background: f64,
|
||||
texture: &Texture,
|
||||
scaling: f64,
|
||||
relief: f64,
|
||||
light: Light,
|
||||
invert: bool,
|
||||
) {
|
||||
if !([foreground, background, scaling, relief].iter().all(|v| v.is_finite()) && scaling > 0.0) {
|
||||
return;
|
||||
}
|
||||
let Some(place) = Place::new(space, w, h) else { return };
|
||||
let fg = foreground.clamp(0.0, 20.0) / 20.0;
|
||||
let bg = background.clamp(0.0, 20.0) / 20.0;
|
||||
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
|
||||
// The tooth: how far the surface stands above its neighbours a quarter point (or half a pixel)
|
||||
// away, so that crayon catches on the peaks and leaves the hollows bare.
|
||||
let step = (0.25f64).max(0.5 * place.px / scaling);
|
||||
let flip = if invert { -1.0 } else { 1.0 };
|
||||
place.each(px, |x, y, p| {
|
||||
if p[3] == 0 {
|
||||
return;
|
||||
}
|
||||
let q = place.at(x, y);
|
||||
let t = Point::new(q.x / scaling, q.y / scaling);
|
||||
let tooth = if t.x.is_finite() && t.y.is_finite() {
|
||||
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
|
||||
let ridge = (at(0.0, 0.0) - 0.25 * (at(-step, 0.0) + at(step, 0.0) + at(0.0, -step) + at(0.0, step))) / step;
|
||||
let tooth = 0.5 + 0.5 * (2.0 * flip * ridge).tanh();
|
||||
if tooth.is_finite() { tooth } else { 0.5 }
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
// Strokes along the two diagonals, about a point across and several long.
|
||||
let (u, v) = ((q.x + q.y) * FRAC_1_SQRT_2, (q.x - q.y) * FRAC_1_SQRT_2);
|
||||
let dark_stroke = value_noise(SALT_CONTE, u * 1.2, v * 0.15);
|
||||
let light_stroke = value_noise(SALT_CONTE + 1, v * 1.2, u * 0.15);
|
||||
let tone = tones.get(x, y);
|
||||
let black = crayon(1.0 - tone, fg, 0.55 * tooth + 0.45 * dark_stroke);
|
||||
let white = crayon(tone, bg, 0.55 * tooth + 0.45 * light_stroke);
|
||||
let paper = 0.4 + 0.2 * tone;
|
||||
let grey = ((paper + (1.0 - paper) * white) * (1.0 - black)) as f32;
|
||||
*p = premultiply([grey; 3], f32::from(p[3]) / 255.0);
|
||||
});
|
||||
if relief > 0.0 {
|
||||
texturizer(px, w, h, space, texture, scaling, relief, light, invert);
|
||||
}
|
||||
}
|
||||
|
||||
/// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance`
|
||||
/// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and
|
||||
/// the fraying; `contrast` (0..20) hardens the split. Coverage is kept.
|
||||
|
||||
@@ -1224,6 +1224,8 @@ fn texture_params_take_defaults_and_stay_in_range() {
|
||||
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||
("artistic.roughPastels", "texture", TEXTURES.as_slice()),
|
||||
("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||
("sketch.conteCrayon", "texture", TEXTURES.as_slice()),
|
||||
("sketch.conteCrayon", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
|
||||
] {
|
||||
let doc = crate::effect_info(id).unwrap().params;
|
||||
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp.
|
||||
//! Sketch › Bas Relief, Charcoal, Conté Crayon, Photocopy and Stamp.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
@@ -173,9 +173,123 @@ fn bas_relief_lights_the_surfaces_facing_the_light() {
|
||||
|
||||
#[test]
|
||||
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
|
||||
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] {
|
||||
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.conteCrayon", "sketch.photocopy", "sketch.stamp"] {
|
||||
let f = fx(id, json!({}));
|
||||
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
|
||||
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
|
||||
}
|
||||
}
|
||||
|
||||
/// The mean of channel 0 over columns `x0..x1` of every row.
|
||||
fn mean(d: &[u8], w: usize, h: usize, x0: usize, x1: usize) -> f64 {
|
||||
let sum: u32 = (0..h).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| u32::from(at(d, w, x, y))).sum();
|
||||
f64::from(sum) / ((x1 - x0) * h) as f64
|
||||
}
|
||||
|
||||
/// How many pixels of channel 0 are lighter than 192.
|
||||
fn white(d: &[u8]) -> usize {
|
||||
d.as_chunks::<4>().0.iter().filter(|p| p[0] > 192).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conte_crayon_params_take_defaults_and_stay_in_range() {
|
||||
let defaults = PixelFx::ConteCrayon {
|
||||
foreground: 12.0,
|
||||
background: 10.0,
|
||||
texture: Texture::Canvas,
|
||||
scaling: 1.0,
|
||||
relief: 4.0,
|
||||
light: Light::Top,
|
||||
invert: false,
|
||||
};
|
||||
assert_eq!(fx("sketch.conteCrayon", json!({})), defaults);
|
||||
assert_eq!(
|
||||
fx(
|
||||
"sketch.conteCrayon",
|
||||
json!({"foregroundLevel": -5, "backgroundLevel": 1e308, "texture": "Brick", "scaling": 10, "relief": 99, "lightDirection": "bottomRight", "invert": true})
|
||||
),
|
||||
PixelFx::ConteCrayon {
|
||||
foreground: 0.0,
|
||||
background: 20.0,
|
||||
texture: Texture::Brick,
|
||||
scaling: 0.5,
|
||||
relief: 50.0,
|
||||
light: Light::BottomRight,
|
||||
invert: true
|
||||
}
|
||||
);
|
||||
for junk in [
|
||||
json!({"foregroundLevel": "NaN", "scaling": "1e999"}),
|
||||
json!({"backgroundLevel": null, "texture": 3, "relief": [1], "lightDirection": "up", "invert": "yes"}),
|
||||
] {
|
||||
assert_eq!(fx("sketch.conteCrayon", junk.clone()), defaults, "{junk}");
|
||||
}
|
||||
let doc = crate::effect_info("sketch.conteCrayon").unwrap().params;
|
||||
assert!(doc.contains("unverified") && doc.contains("foregroundLevel") && doc.contains("backgroundLevel"), "{doc}");
|
||||
}
|
||||
|
||||
/// A grey ramp: its shadows in black crayon, its highlights in white crayon, the midtones on grey
|
||||
/// paper between them; grey only, coverage kept, the same on every run.
|
||||
#[test]
|
||||
fn conte_crayon_draws_the_shadows_black_and_the_highlights_white() {
|
||||
let (w, h) = (96, 32);
|
||||
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
|
||||
let out = run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h);
|
||||
for p in out.as_chunks::<4>().0 {
|
||||
assert!(p[0] == p[1] && p[1] == p[2] && p[3] == 255, "{p:?}");
|
||||
}
|
||||
let (shadows, mids, lights) = (mean(&out, w, h, 0, 12), mean(&out, w, h, w / 2 - 6, w / 2 + 6), mean(&out, w, h, w - 12, w));
|
||||
assert!(shadows < 40.0, "shadows {shadows}");
|
||||
assert!(lights > 215.0, "highlights {lights}");
|
||||
assert!(shadows + 30.0 < mids && mids + 30.0 < lights, "{shadows} {mids} {lights}");
|
||||
assert_eq!(out, run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h), "deterministic");
|
||||
// The relief shades the paper but keeps it grey.
|
||||
let lit = run("sketch.conteCrayon", json!({}), &src, w, h);
|
||||
assert_ne!(lit, out);
|
||||
assert!(lit.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]));
|
||||
}
|
||||
|
||||
/// Foreground Level spreads the black crayon into the midtones, Background Level the white one.
|
||||
#[test]
|
||||
fn conte_crayon_levels_spread_each_crayon_into_the_midtones() {
|
||||
let (w, h) = (64, 64);
|
||||
let mid = image(w, h, |_, _| grey(128));
|
||||
let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h);
|
||||
let (few, many) = (with(json!({"foregroundLevel": 0, "relief": 0})), with(json!({"foregroundLevel": 20, "relief": 0})));
|
||||
assert!(black(&many) > black(&few) + w * h / 4, "{} vs {}", black(&many), black(&few));
|
||||
let (few, many) = (
|
||||
with(json!({"foregroundLevel": 0, "backgroundLevel": 0, "relief": 0})),
|
||||
with(json!({"foregroundLevel": 0, "backgroundLevel": 20, "relief": 0})),
|
||||
);
|
||||
assert!(white(&many) > white(&few) + w * h / 4, "{} vs {}", white(&many), white(&few));
|
||||
}
|
||||
|
||||
/// The paper's texture, scale and inversion break the crayon differently; the relief is lit from
|
||||
/// the chosen side.
|
||||
#[test]
|
||||
fn conte_crayon_texture_options_change_the_paper() {
|
||||
let (w, h) = (64, 64);
|
||||
let mid = image(w, h, |x, y| grey((64 + x + y) as u8));
|
||||
let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h);
|
||||
let canvas = with(json!({"relief": 0}));
|
||||
for other in [
|
||||
json!({"relief": 0, "texture": "brick"}),
|
||||
json!({"relief": 0, "texture": "sandstone"}),
|
||||
json!({"relief": 0, "scaling": 200}),
|
||||
json!({"relief": 0, "invert": true}),
|
||||
] {
|
||||
assert_ne!(with(other.clone()), canvas, "{other}");
|
||||
}
|
||||
let top = with(json!({"texture": "brick", "relief": 20, "lightDirection": "top"}));
|
||||
let bottom = with(json!({"texture": "brick", "relief": 20, "lightDirection": "bottom"}));
|
||||
assert_ne!(top, bottom);
|
||||
assert_ne!(top, with(json!({"texture": "brick", "relief": 0, "lightDirection": "top"})));
|
||||
survives(
|
||||
"sketch.conteCrayon",
|
||||
&[
|
||||
json!({}),
|
||||
json!({"foregroundLevel": "NaN", "backgroundLevel": 1e308, "scaling": -1e308, "relief": 1e308, "texture": "burlap", "invert": true}),
|
||||
json!({"foregroundLevel": -1e308, "backgroundLevel": [1], "texture": {}, "lightDirection": 3, "relief": "NaN"}),
|
||||
],
|
||||
);
|
||||
}
|
||||
+143
-29
@@ -16,7 +16,7 @@ use std::sync::Arc;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
|
||||
|
||||
use crate::{GeomContext, apply_one, is_geometry, merged_params};
|
||||
use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params};
|
||||
|
||||
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
|
||||
/// neither a path nor a container)?
|
||||
@@ -36,7 +36,7 @@ pub fn needs_outline(n: &Node) -> bool {
|
||||
|
||||
/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient
|
||||
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
|
||||
/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do.
|
||||
/// paints its gradient by itself ([`copy_leaf`]), as a group's copies do.
|
||||
pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
|
||||
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
|
||||
@@ -45,6 +45,26 @@ pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path with a visible Transform effect whose Transform Patterns or
|
||||
/// Scale Strokes & Effects is on (#885)? Such an effect changes the fills and strokes as well as
|
||||
/// the geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of
|
||||
/// its own with its strokes scaled and its patterns moved ([`transform_leaf`]).
|
||||
pub fn has_transform_options(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
|
||||
&& geometry_effects(n)
|
||||
.iter()
|
||||
// Both options default to off, so the stored params tell without merging the defaults
|
||||
// (this runs for every object drawn).
|
||||
.any(|e| e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance())
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
|
||||
/// ([`has_gradient_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
|
||||
/// that art ([`reshape`]).
|
||||
pub fn has_transform_art(n: &Node) -> bool {
|
||||
has_gradient_copies(n) || has_transform_options(n)
|
||||
}
|
||||
|
||||
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
|
||||
/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape
|
||||
/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke
|
||||
@@ -205,7 +225,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
Node::group(n.id, vec![Arc::new(a)])
|
||||
}
|
||||
// The object as the one piece the copies of its Transform effect are made from.
|
||||
_ if has_gradient_copies(n) => {
|
||||
_ if has_transform_art(n) => {
|
||||
let mut one = n.clone();
|
||||
one.opacity = 1.0;
|
||||
one.blend = Default::default();
|
||||
@@ -260,19 +280,20 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
|
||||
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
|
||||
/// or has no art (a symbol instance without `symbol_art`).
|
||||
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
|
||||
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) {
|
||||
return None;
|
||||
}
|
||||
as_art(n, symbol_art)
|
||||
}
|
||||
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
|
||||
/// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand
|
||||
/// Appearance: [`reshape`]d when it has
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
|
||||
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
|
||||
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
|
||||
if has_gradient_copies(n) {
|
||||
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
|
||||
// patterns: each copy an object of its own.
|
||||
if has_transform_art(n) {
|
||||
return as_art(n, symbol_art);
|
||||
}
|
||||
let own_paint = match n.kind {
|
||||
@@ -372,65 +393,146 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
|
||||
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
|
||||
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
|
||||
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp).
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
|
||||
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
|
||||
/// whose effect would pass what the others leave of it keeps its shape.
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS);
|
||||
}
|
||||
|
||||
/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost.
|
||||
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) {
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
let seed = art.id.0;
|
||||
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
|
||||
// pieces, from the outlines' bounds before the effects to their bounds after them.
|
||||
let mut outlines = vec![];
|
||||
type_outline_bounds(art, &mut outlines);
|
||||
let mut used = art_cost(art);
|
||||
let max = budget.at_least(used);
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed));
|
||||
let transform = (e.id == "distort.transform").then(|| {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
(m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed))
|
||||
});
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
// A gradient paints each copy by itself, not the one piece the copies make together.
|
||||
if let Some((m, copies)) = transform
|
||||
&& copies > 0
|
||||
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
|
||||
{
|
||||
copy_with_gradients(leaf, &path, m, copies);
|
||||
return;
|
||||
// What the other paths leave of the budget.
|
||||
let before = Budget::of(&path);
|
||||
let room = max.minus(used.minus(before));
|
||||
if let Some((m, copies, options, map)) = transform {
|
||||
// Transform Patterns and Scale Strokes & Effects change each copy's fills and
|
||||
// strokes; a gradient paints each copy by itself, not the one piece the copies
|
||||
// make together.
|
||||
let gradient = copies > 0 && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient());
|
||||
if options.reach_appearance() {
|
||||
let made = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
used = used.minus(before).plus(made);
|
||||
return;
|
||||
}
|
||||
if gradient {
|
||||
let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room);
|
||||
used = used.minus(before).plus(made);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { return };
|
||||
used = used.minus(before).plus(Budget::of(&out));
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if let Some((m, copies)) = transform {
|
||||
if let Some((m, copies, options, _)) = transform
|
||||
&& options.objects
|
||||
{
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
}
|
||||
if !fx.is_empty() && outlines.iter().any(Option::is_some) {
|
||||
if outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by
|
||||
/// (its copies stop before one would take more), so stroke weights stay finite where the geometry
|
||||
/// doesn't grow with them (a horizontal line stretched vertically).
|
||||
const MAX_STROKE_SCALE: f64 = 1.0e6;
|
||||
|
||||
/// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with
|
||||
/// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object
|
||||
/// › Transform with those options transforms an object (#885): the strokes' weights and dashes
|
||||
/// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its
|
||||
/// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them
|
||||
/// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform
|
||||
/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after.
|
||||
fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, options: crate::distort::TransformOptions, budget: Budget) -> Budget {
|
||||
let scaling = vectorcraft_doc::Scaling {
|
||||
strokes: options.strokes,
|
||||
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
|
||||
patterns: options.patterns,
|
||||
..Default::default()
|
||||
};
|
||||
if !options.objects {
|
||||
if options.strokes
|
||||
&& let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0)
|
||||
{
|
||||
leaf.appearance.scale_strokes(k);
|
||||
leaf.appearance.scale_effects(k, crate::scale_effect);
|
||||
}
|
||||
if options.patterns {
|
||||
vectorcraft_doc::pattern::transform_pattern_paints(leaf, m);
|
||||
}
|
||||
return Budget::of(path);
|
||||
}
|
||||
if copies == 0 {
|
||||
// A path scaled up is drawn with more lines; past the budget it keeps its shape.
|
||||
let moved = path.transformed(m);
|
||||
if !budget.fits(Budget::of(&moved)) {
|
||||
return Budget::of(path);
|
||||
}
|
||||
leaf.transform(m, scaling);
|
||||
return Budget::of(&moved);
|
||||
}
|
||||
copy_leaf(leaf, path, m, copies, scaling, budget)
|
||||
}
|
||||
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each
|
||||
/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather
|
||||
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would.
|
||||
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) {
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
/// more, each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies
|
||||
/// an object. Each copy is an object of its own, so its gradients fit it (a placed gradient moves
|
||||
/// with it) rather than spanning every copy, and its strokes and patterns can differ from the
|
||||
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
|
||||
/// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the
|
||||
/// copies cost, the original included.
|
||||
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
|
||||
let mut made = Budget::of(path);
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > crate::distort::MAX_COPY_ANCHORS {
|
||||
xf *= m;
|
||||
// A copy scaled up is drawn with more lines.
|
||||
let cost = Budget::of(&path.transformed(xf));
|
||||
if !budget.fits(made.plus(cost)) {
|
||||
break;
|
||||
}
|
||||
if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) {
|
||||
break;
|
||||
}
|
||||
xf *= m;
|
||||
let mut copy = leaf.clone();
|
||||
copy.transform(xf, vectorcraft_doc::Scaling::default());
|
||||
copy.transform(xf, scaling);
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
made = made.plus(cost);
|
||||
}
|
||||
// The copies become nodes of their own under a group with the object's id and name.
|
||||
let mut g = Node::group(leaf.id, placements);
|
||||
g.name = leaf.name.clone();
|
||||
*leaf = g;
|
||||
made
|
||||
}
|
||||
|
||||
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
|
||||
@@ -597,6 +699,18 @@ fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the paths under `n` that [`for_each_leaf`] visits cost together, as [`leaf_geometry`]
|
||||
/// gives them.
|
||||
fn art_cost(n: &Node) -> Budget {
|
||||
let none = Budget { anchors: 0, lines: 0 };
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, path, .. } => Budget::of(path),
|
||||
NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).map(Budget::of).fold(none, Budget::plus),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| art_cost(c)).fold(none, Budget::plus),
|
||||
_ => none,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
|
||||
|
||||
@@ -181,7 +181,7 @@ fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> C
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops =
|
||||
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
use kurbo::{BezPath, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
|
||||
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color};
|
||||
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
|
||||
use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
@@ -375,7 +375,7 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines:
|
||||
/// stroke's outline), coloured as [`gradient_slices`] paint it.
|
||||
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
|
||||
let across = st.gradient_mode == StrokeGradientMode::Across;
|
||||
let color = |t: f64| g.sample_with(t.clamp(0.0, 1.0) as f32, lerp_color);
|
||||
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
|
||||
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
|
||||
let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() };
|
||||
let max_dt = match (across, st.profile.is_some()) {
|
||||
|
||||
@@ -287,6 +287,153 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
|
||||
assert_eq!(count(&evaluate_container(&g).unwrap()), 1001);
|
||||
}
|
||||
|
||||
/// What `effects` make of `path` within `budget`.
|
||||
fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData {
|
||||
apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget)
|
||||
}
|
||||
|
||||
/// What `effects` make of `path` within a budget of `max` anchors (and any number of lines).
|
||||
fn anchors_within(effects: &[Effect], path: &PathData, max: usize) -> PathData {
|
||||
within(effects, path, Budget { anchors: max, lines: usize::MAX })
|
||||
}
|
||||
|
||||
/// #3578: stacked Roughens multiply their points (up to 2000 a segment); the anchors they make
|
||||
/// stay within the budget, each Roughen spacing its points further apart where it must.
|
||||
#[test]
|
||||
fn stacked_roughens_stay_within_the_anchor_budget() {
|
||||
let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100}));
|
||||
let outs: Vec<PathData> = (1..=3).map(|n| anchors_within(&vec![rough.clone(); n], &square(), 10_000)).collect();
|
||||
let counts: Vec<usize> = outs.iter().map(PathData::anchor_count).collect();
|
||||
// 100 pt sides at 100 points per inch: 139 points each, within the budget.
|
||||
assert_eq!(counts[0], 4 * 139);
|
||||
assert_eq!(outs[0], run("distort.roughen", rough.params.clone(), &square()));
|
||||
assert!(counts[1..].iter().all(|&c| c > 5_000 && c <= 10_000), "{counts:?}");
|
||||
// The third Roughen still roughens, with its points spaced to fit.
|
||||
assert_ne!(outs[2], outs[1]);
|
||||
assert!(outs.iter().all(all_finite));
|
||||
}
|
||||
|
||||
/// #3578: stacked Zig Zags multiply their points (up to 101 a segment); each segment gets as many
|
||||
/// ridges as fit the budget.
|
||||
#[test]
|
||||
fn stacked_zig_zags_stay_within_the_anchor_budget() {
|
||||
let zig = fx("distort.zigZag", json!({"ridges": 100, "points": "corner"}));
|
||||
let counts: Vec<usize> = (1..=3).map(|n| anchors_within(&vec![zig.clone(); n], &square(), 10_000).anchor_count()).collect();
|
||||
// 4 × 101 points; then 23 ridges a segment fit (404 × 24); then none, each point displaced.
|
||||
assert_eq!(counts, [404, 9_696, 9_696]);
|
||||
let twice = anchors_within(&vec![zig.clone(); 2], &square(), 10_000);
|
||||
assert_ne!(anchors_within(&vec![zig.clone(); 3], &square(), 10_000), twice);
|
||||
// An open path's end point counts as well.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
|
||||
assert_eq!(anchors_within(&[zig], &line, 30).anchor_count(), 30, "the start, 28 ridges and the end");
|
||||
}
|
||||
|
||||
/// #3578: every effect that multiplies anchors builds within the budget, stacked or not, and one
|
||||
/// that doesn't (Round Corners doubles them) is left out where it would pass it.
|
||||
#[test]
|
||||
fn stacked_geometry_effects_stay_within_the_anchor_budget() {
|
||||
let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 5, 0.0);
|
||||
for (id, params) in [
|
||||
("distort.roughen", json!({"size": 50, "detail": 100})),
|
||||
("distort.zigZag", json!({"ridges": 100, "size": 30})),
|
||||
("distort.twist", json!({"angle": 3600})),
|
||||
("distort.freeDistort", json!({"corners": [[0.3, -2], [1, 0], [4, 1], [0, 3]]})),
|
||||
("warp.twist", json!({"bend": 100})),
|
||||
("distort.transform", json!({"copies": 1000, "moveH": 1})),
|
||||
] {
|
||||
for n in 1..=4 {
|
||||
let out = anchors_within(&vec![fx(id, params.clone()); n], &star, 2_000);
|
||||
assert!(out.anchor_count() <= 2_000 && all_finite(&out), "{id} × {n}: {}", out.anchor_count());
|
||||
}
|
||||
}
|
||||
let zig = fx("distort.zigZag", json!({"ridges": 100, "points": "corner"}));
|
||||
let full = anchors_within(&[zig.clone(), zig.clone()], &square(), 10_000);
|
||||
let round = fx("stylize.roundCorners", json!({"radius": 1}));
|
||||
assert_eq!(anchors_within(&[zig.clone(), zig.clone(), round.clone()], &square(), 10_000), full);
|
||||
assert_ne!(anchors_within(&[zig, round], &square(), 10_000), run("distort.zigZag", json!({"ridges": 100, "points": "corner"}), &square()));
|
||||
}
|
||||
|
||||
/// #3578: a path with more anchors than the budget keeps its count as its budget: effects that
|
||||
/// add none apply, and the others add none.
|
||||
#[test]
|
||||
fn a_path_past_the_anchor_budget_still_takes_effects() {
|
||||
let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 25, 0.0);
|
||||
let n = star.anchor_count();
|
||||
for (id, params) in [
|
||||
("distort.tweak", json!({})),
|
||||
("distort.roughen", json!({"detail": 100})),
|
||||
("distort.zigZag", json!({"ridges": 100})),
|
||||
("distort.twist", json!({"angle": 90})),
|
||||
] {
|
||||
let out = anchors_within(&[fx(id, params)], &star, 10);
|
||||
assert_eq!(out.anchor_count(), n, "{id}");
|
||||
assert_ne!(out, star, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3578: the effects on type, symbols and images reshape all their paths within one budget.
|
||||
#[test]
|
||||
fn stacked_effects_on_art_stay_within_the_anchor_budget() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::NodeId;
|
||||
let id = NodeId(9);
|
||||
let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default()));
|
||||
let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect());
|
||||
let rough = fx("distort.roughen", json!({"size": 50, "detail": 100}));
|
||||
crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX });
|
||||
let mut total = 0;
|
||||
let mut reshaped = 0;
|
||||
for c in art.children().into_iter().flatten() {
|
||||
let path = c.path_data().unwrap();
|
||||
total += path.anchor_count();
|
||||
reshaped += usize::from(path.anchor_count() > 4);
|
||||
}
|
||||
// One Roughen alone makes 10 × 556: the budget binds, and goes to the paths in turn.
|
||||
assert!(total > 4_000 && total <= 5_000, "{total}");
|
||||
assert_eq!(reshaped, 10);
|
||||
}
|
||||
|
||||
/// #3578: a large Roughen draws each of its points as many lines; they stay within the budget,
|
||||
/// the Roughen built again with fewer points where they wouldn't.
|
||||
#[test]
|
||||
fn large_roughens_stay_within_the_line_budget() {
|
||||
let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100}));
|
||||
let budget = Budget { anchors: 100_000, lines: 200_000 };
|
||||
let once = within(std::slice::from_ref(&rough), &square(), budget);
|
||||
assert_eq!(once, run("distort.roughen", rough.params.clone(), &square()), "within both budgets");
|
||||
let twice = within(&[rough.clone(), rough], &square(), budget);
|
||||
let cost = Budget::of(&twice);
|
||||
assert!(budget.fits(cost) && cost.lines > 100_000, "{cost:?}");
|
||||
assert!(cost.anchors < 10_000 && twice != once, "the second Roughen with fewer points: {cost:?}");
|
||||
// Art of small curves draws its anchors as a few lines each: the anchors bind first.
|
||||
let zig = fx("distort.zigZag", json!({"ridges": 100, "size": 2}));
|
||||
let out = within(&[zig.clone(), zig], &square(), Budget { anchors: 10_000, lines: 100_000 });
|
||||
assert!(out.anchor_count() > 9_000 && util::line_estimate(&out) < 50_000, "{}", util::line_estimate(&out));
|
||||
}
|
||||
|
||||
/// Straight segments are one line each, and a curve as many as Wang's formula takes at 0.25 px.
|
||||
#[test]
|
||||
fn line_estimate_counts_the_lines_a_path_is_drawn_with() {
|
||||
assert_eq!(util::line_estimate(&square()), 4);
|
||||
let circle = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert_eq!(util::line_estimate(&circle), 4 * 9, "a quarter circle of 50 pt in 9 lines");
|
||||
let big = shapes::ellipse(Rect::new(0.0, 0.0, 10_000.0, 10_000.0));
|
||||
assert_eq!(util::line_estimate(&big), 4 * 84, "100 times as large: about 10 times the lines");
|
||||
}
|
||||
|
||||
/// #3578: the issue's input, a large rectangle with three max-detail Roughens of the largest
|
||||
/// size, at the real budgets: two million anchors of it were 141 million lines to draw.
|
||||
#[test]
|
||||
fn three_max_detail_roughens_stay_within_the_effect_budget() {
|
||||
let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100}));
|
||||
let rect = shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0));
|
||||
let out = apply_geometry(&vec![rough.clone(); 3], &rect, rect.bounds().unwrap());
|
||||
let cost = Budget::of(&out);
|
||||
assert!(Budget::EFFECTS.fits(cost) && cost.lines > MAX_EFFECT_LINES / 2, "{cost:?}");
|
||||
assert_ne!(out, apply_geometry(&[rough], &rect, rect.bounds().unwrap()), "more than one Roughen applies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_stop_before_they_overflow() {
|
||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||
|
||||
@@ -286,7 +286,7 @@ fn a_masked_surface_under_a_colored_light_stays_whole() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let mut half = GradientStop::new(1.0, RED);
|
||||
half.opacity = 0.5;
|
||||
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
|
||||
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
|
||||
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
|
||||
let mut n = stroked_rect(100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The Transform effect's Transform Objects, Transform Patterns and Scale Strokes & Effects
|
||||
//! options (#885).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Dash, Effect, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use crate::distort::{TransformOptions, transform_map, transform_move};
|
||||
use crate::{has_transform_art, has_transform_options, reshape};
|
||||
|
||||
fn transform(params: Value) -> Effect {
|
||||
Effect { id: "distort.transform".into(), params, visible: true }
|
||||
}
|
||||
|
||||
/// A 100 pt square at the origin with a 2 pt black stroke (dashed 4/2) and the Transform effect
|
||||
/// `params`.
|
||||
fn stroked(params: Value) -> Node {
|
||||
let mut n =
|
||||
Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
|
||||
if let Some(s) = n.appearance.stroke_mut() {
|
||||
s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false });
|
||||
}
|
||||
n.appearance.effects.push(transform(params));
|
||||
n
|
||||
}
|
||||
|
||||
/// A 100 pt square at the origin filled with pattern "p" placed at the identity, with the
|
||||
/// Transform effect `params`.
|
||||
fn patterned(params: Value) -> Node {
|
||||
let paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY };
|
||||
let mut n = Node::path(NodeId(8), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(paint, Paint::None, 0.0));
|
||||
n.appearance.effects.push(transform(params));
|
||||
n
|
||||
}
|
||||
|
||||
/// The paths under `n`, in document order.
|
||||
fn leaves(n: &Node) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
fn walk<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
|
||||
match n.children() {
|
||||
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().for_each(|c| walk(c, out)),
|
||||
_ => out.push(n),
|
||||
}
|
||||
}
|
||||
walk(n, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn stroke_of(n: &Node) -> (f64, Vec<f64>) {
|
||||
let s = n.appearance.stroke().unwrap();
|
||||
(s.width, s.dash.as_ref().map(|d| d.pattern.clone()).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn pattern_xf(n: &Node) -> Affine {
|
||||
match n.appearance.fill_paint() {
|
||||
Paint::Pattern { xf, .. } => xf,
|
||||
p => panic!("not a pattern: {p:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_options_default_to_what_the_effect_did_before() {
|
||||
let o = TransformOptions::of(&crate::merged_params("distort.transform", &json!({})));
|
||||
assert_eq!(o, TransformOptions { objects: true, patterns: false, strokes: false });
|
||||
assert!(!o.reach_appearance());
|
||||
// An old effect, with no options stored, still draws as geometry alone.
|
||||
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "copies": 2}));
|
||||
assert!(!has_transform_options(&n));
|
||||
assert!(!has_transform_art(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_objects_off_maps_nothing_and_makes_no_copies() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let (m, copies) = transform_map(b, &json!({"scaleH": 50, "moveH": 30, "copies": 3, "objects": false}), 0);
|
||||
assert_eq!((m, copies), (Affine::IDENTITY, 0));
|
||||
// A plain move with Transform Patterns on isn't a plain move any more: the pattern moves too.
|
||||
assert!(transform_move(&json!({"moveH": 30})).is_some());
|
||||
assert!(transform_move(&json!({"moveH": 30, "patterns": true})).is_none());
|
||||
assert!(transform_move(&json!({"moveH": 30, "strokes": true})).is_some(), "a move scales nothing");
|
||||
assert!(transform_move(&json!({"scaleH": 50, "objects": false, "strokes": true})).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_strokes_and_effects_scales_the_stroke_and_dash_with_the_object() {
|
||||
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "strokes": true}));
|
||||
assert!(has_transform_art(&n));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let paths = leaves(&art);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let (w, dash) = stroke_of(paths[0]);
|
||||
assert!(close(w, 1.0), "{w}");
|
||||
assert_eq!(dash, vec![2.0, 1.0]);
|
||||
let b = paths[0].geometric_bounds().unwrap();
|
||||
assert!(close(b.width(), 50.0), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_copy_scales_its_stroke_once_more() {
|
||||
let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let widths: Vec<f64> = leaves(&art).iter().map(|p| stroke_of(p).0).collect();
|
||||
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
|
||||
// Without the option the copies keep the original's weight.
|
||||
let plain = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2}));
|
||||
assert!(!has_transform_art(&plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_objects_off_keeps_the_geometry_and_scales_the_stroke_once() {
|
||||
let n = stroked(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true}));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let paths = leaves(&art);
|
||||
assert_eq!(paths.len(), 1, "no copies");
|
||||
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(close(stroke_of(paths[0]).0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_patterns_moves_each_copys_pattern() {
|
||||
let n = patterned(json!({"moveH": 30, "copies": 2, "patterns": true}));
|
||||
assert!(has_transform_options(&n));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let xs: Vec<f64> = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
|
||||
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
|
||||
// Without it, every copy's pattern stays put (the tiles don't follow the copies).
|
||||
let n = patterned(json!({"moveH": 30, "patterns": false, "strokes": false, "objects": true}));
|
||||
assert!(!has_transform_art(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_patterns_with_objects_off_moves_only_the_pattern() {
|
||||
let n = patterned(json!({"rotate": 45, "objects": false, "patterns": true}));
|
||||
let art = reshape(&n, None).unwrap();
|
||||
let paths = leaves(&art);
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert_ne!(pattern_xf(paths[0]), Affine::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_scaling_across_copies_stays_finite() {
|
||||
// A horizontal line stretched vertically barely grows, but its stroke would: the copies stop
|
||||
// before a stroke weight runs away.
|
||||
let mut n =
|
||||
Node::path(NodeId(9), shapes::line((0.0, 0.0).into(), (100.0, 0.0).into()), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0));
|
||||
n.appearance.effects.push(transform(json!({"scaleH": 4000, "scaleV": 4000, "copies": 1000, "strokes": true})));
|
||||
if let Some(art) = reshape(&n, None) {
|
||||
for p in leaves(&art) {
|
||||
let w = stroke_of(p).0;
|
||||
assert!(w.is_finite() && w <= 1.0e6, "{w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Parameter parsing, deterministic noise and path-mapping helpers.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::{Anchor, CubicBez, ParamCurve, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Anchor, CubicBez, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
/// Number param (also accepts numeric strings such as `"10 pt"`); non-finite → default.
|
||||
pub fn num(p: &Value, key: &str, default: f64) -> f64 {
|
||||
@@ -69,8 +69,25 @@ pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
|
||||
if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c }
|
||||
}
|
||||
|
||||
/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`).
|
||||
pub use vectorcraft_doc::live::map_nonlinear;
|
||||
/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`), and the split of
|
||||
/// segments into pieces within an anchor budget.
|
||||
pub use vectorcraft_doc::live::{fit_piece, map_nonlinear_within, pieces};
|
||||
|
||||
/// About how many lines the renderer flattens `path` into at 1 px/pt: one for a straight
|
||||
/// segment, and for a curve the count Wang's formula gives at vello's tolerance of 0.25 px,
|
||||
/// `sqrt(0.75 × d / 0.25)` for the largest second difference `d` of its control points.
|
||||
pub fn line_estimate(path: &PathData) -> usize {
|
||||
let curve = |c: CubicBez| {
|
||||
let d =
|
||||
(c.p0.to_vec2() - c.p1.to_vec2() * 2.0 + c.p2.to_vec2()).hypot().max((c.p1.to_vec2() - c.p2.to_vec2() * 2.0 + c.p3.to_vec2()).hypot());
|
||||
// A NaN casts to 0 and an infinity to the largest count.
|
||||
((3.0 * d).sqrt().ceil() as usize).max(1)
|
||||
};
|
||||
path.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| (0..sp.segment_count()).map(move |i| if sp.segment_is_line(i) { 1 } else { curve(sp.segment(i)) }))
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Smooth subpath through `pts` (Catmull-Rom tangents, see [`turn_tangent`] for sharp turns).
|
||||
pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::util::*;
|
||||
|
||||
pub use vectorcraft_doc::live::{WarpStyle, warp_point};
|
||||
|
||||
/// Apply a warp to `path` using box `b`.
|
||||
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
|
||||
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
|
||||
let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0;
|
||||
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
|
||||
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
|
||||
@@ -30,5 +30,5 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
};
|
||||
let piece = b.width().hypot(b.height()) / 24.0;
|
||||
map_nonlinear(path, piece, f)
|
||||
map_nonlinear_within(path, piece, max, f)
|
||||
}
|
||||
@@ -372,8 +372,10 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
|
||||
}
|
||||
|
||||
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
|
||||
/// targeted item (on the first target object), else `default`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool) -> Result<bool> {
|
||||
/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or
|
||||
/// null is an error of command `cmd`.
|
||||
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
|
||||
check_switches(p, &["stroke"], cmd)?;
|
||||
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
|
||||
return Ok(b);
|
||||
}
|
||||
@@ -486,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if source == target {
|
||||
return Err(bad(C, "`source` and `target` are the same object"));
|
||||
}
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let copy = switch_or(p, "copy", false, C)?;
|
||||
// When both objects come out as they were, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
|
||||
@@ -549,6 +551,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = index_param(p, "index", "appearance.setItem")?;
|
||||
super::paint::check_keep_model(p, "appearance.setItem")?;
|
||||
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
|
||||
check_switches(p, &["visible"], "appearance.setItem")?;
|
||||
let paint = paint_from(s, p)?;
|
||||
let ids = appearance_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
@@ -762,7 +766,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false));
|
||||
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
|
||||
let selected = match ids_param(p, "ids") {
|
||||
|
||||
@@ -970,7 +970,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let name = resolve_symbol(d, p, "symbol.place")?;
|
||||
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
|
||||
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
|
||||
let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?);
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
@@ -1099,7 +1099,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
|
||||
return Err(bad(C, format!("no symbol named `{name}`")));
|
||||
}
|
||||
let expand = bool_or(p, "expandInstances", true);
|
||||
let expand = switch_or(p, "expandInstances", true, C)?;
|
||||
s.edit("Delete Symbol", |d, _| {
|
||||
let inst = instances_of(d, Some(&name));
|
||||
if expand {
|
||||
@@ -1216,10 +1216,10 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points(p, C)?;
|
||||
let st = s.doc()?;
|
||||
let name = resolve_symbol(&st.doc, p, C)?;
|
||||
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
|
||||
let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0);
|
||||
let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0);
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
|
||||
let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0);
|
||||
let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0);
|
||||
let alt = switch_or(p, "alt", false, C)?;
|
||||
let size = natural_size(&st.doc, &name);
|
||||
let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
|
||||
// Only a new Symbol Set needs a layer that takes new art.
|
||||
@@ -1303,9 +1303,9 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let pts = points(p, C)?;
|
||||
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0);
|
||||
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0;
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
|
||||
let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0;
|
||||
let alt = switch_or(p, "alt", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let color = match p.get("color").and_then(color_value) {
|
||||
Some(c) => super::paint::new_color(s, p, c),
|
||||
|
||||
@@ -24,6 +24,7 @@ use vectorcraft_tools::builder::{
|
||||
};
|
||||
use vectorcraft_trace as tr;
|
||||
|
||||
use super::draw2::replace_with_pieces;
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
@@ -39,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -242,16 +243,38 @@ fn gap_param(p: &Value) -> f64 {
|
||||
if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The Shift-drag marquee `rect: [x, y, width, height]`, as the point where the drag started
|
||||
/// (`x`, `y`) and the opposite corner; the width and height may be negative.
|
||||
fn marquee_param(p: &Value, cmd: &str) -> Result<Option<(Point, Point)>> {
|
||||
let Some(v) = p.get("rect").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let corners = finite_numbers(v).map(|[x, y, w, h]| (Point::new(x, y), Point::new(x + w, y + h)));
|
||||
match corners {
|
||||
Some((a, b)) if finite(a) && finite(b) => Ok(Some((a, b))),
|
||||
_ => Err(bad(cmd, "rect must be [x, y, width, height]")),
|
||||
}
|
||||
}
|
||||
|
||||
fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "shapeBuilder.merge";
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?;
|
||||
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
|
||||
let marquee = marquee_param(p, C)?;
|
||||
let pts = match points_param(p, "points") {
|
||||
Some(pts) => pts,
|
||||
// A marquee needs no drag path.
|
||||
None if marquee.is_some() && p.get("points").is_none_or(Value::is_null) => vec![],
|
||||
None => return Err(bad(C, "missing points [[x,y]…]")),
|
||||
};
|
||||
if (pts.is_empty() && marquee.is_none()) || !pts.iter().all(|q| finite(*q)) {
|
||||
return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
|
||||
}
|
||||
let erase = bool_or(p, "erase", false);
|
||||
let erase = switch_or(p, "erase", false, C)?;
|
||||
check_numbers(p, &["tolerance", "gap"], C)?;
|
||||
// Line pieces and edges are only deleted (erase mode), never merged.
|
||||
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||
let open_filled_as_closed = bool_or(p, "openFilledAsClosed", true);
|
||||
// In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it; a
|
||||
// marquee does not.
|
||||
let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1 && marquee.is_none())
|
||||
.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
|
||||
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?;
|
||||
let swatches = match p.get("pickColorFrom") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => match v.as_str() {
|
||||
@@ -260,21 +283,52 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))),
|
||||
},
|
||||
};
|
||||
let current_fill = s.paint.fill.clone();
|
||||
let fill = match fill_param(s, p, C)? {
|
||||
None if swatches => Some(current_fill.clone()),
|
||||
None if swatches => Some(s.paint.fill.clone()),
|
||||
f => f,
|
||||
};
|
||||
let roots = root_ids(s, p)?;
|
||||
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
|
||||
let (ids, merged, touched) = s.edit(label, |d, sel| {
|
||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p));
|
||||
let (ids, merged, touched, split) = s.edit(label, |d, sel| {
|
||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase || split_tol.is_some(), open_filled_as_closed, gap_param(p));
|
||||
let outlines = b::drawn_outlines(d, &leaves);
|
||||
let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
|
||||
if map.shapes.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
|
||||
}
|
||||
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
|
||||
let mut touched = Touched::default();
|
||||
if let Some(tol) = split_tol
|
||||
&& let Some(&q) = pts.first()
|
||||
&& let Some((i, run)) = map.stroke_at(&outlines, q, tol)
|
||||
&& let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i))
|
||||
&& let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn))
|
||||
&& rest.length() > b::outline_snap(drawn)
|
||||
{
|
||||
// The path keeps the rest of it, and a copy of it with the run under the click goes
|
||||
// directly above it; the other objects stay as they are. Gradients fitted to the
|
||||
// path's bounds keep that fit on both.
|
||||
if let Some(n) = d.node_mut(leaf.id) {
|
||||
n.pin_gradients();
|
||||
}
|
||||
let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect();
|
||||
let mut replaced = Beside::replacing(d, &[leaf.id]);
|
||||
let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?;
|
||||
replaced.finish(d)?;
|
||||
let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?;
|
||||
// Pick Color From Color Swatches (or `fill`): the stroke takes that paint.
|
||||
if let Some(f) = &fill
|
||||
&& let Some(n) = d.node_mut(split)
|
||||
{
|
||||
n.appearance.set_stroke(f.clone());
|
||||
}
|
||||
// The roots stay selected, with the two paths in place of the path when it was one.
|
||||
let ids: Vec<NodeId> = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect();
|
||||
sel.set(ids.iter().copied());
|
||||
let mut touched = Touched::default();
|
||||
touched.add(map.hit(q, Some(tol)));
|
||||
return Ok((ids, None, touched, Some(split)));
|
||||
}
|
||||
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
|
||||
for q in sample_polyline(&pts, step) {
|
||||
touched.add(map.hit(q, piece_tol));
|
||||
}
|
||||
@@ -283,16 +337,22 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
||||
}
|
||||
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
|
||||
// Edges come back from the planar map within a few hundredths of the outlines they follow.
|
||||
let cut_tol = step * 0.2;
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
|
||||
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
||||
// The merged shape takes the look of the front-most object over the first region, or (for an
|
||||
// area only lines enclose) of the front-most object and the current fill.
|
||||
let covered = picked.iter().find_map(|r| r.top());
|
||||
// Pick Color From Artwork: the merged shape takes the look of the front-most object under the
|
||||
// drag's first point, else under its last point, else of the front-most object. Color
|
||||
// Swatches: the current fill, with the rest of the look of the front-most object over the
|
||||
// first region the drag touched that an object covers, else of the front-most object. A
|
||||
// marquee's drag starts at its first corner and ends at the opposite one.
|
||||
let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top());
|
||||
let (first, last) = match marquee {
|
||||
Some((a, b)) => (Some(a), Some(b)),
|
||||
None => (pts.first().copied(), pts.last().copied()),
|
||||
};
|
||||
let covered =
|
||||
if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) };
|
||||
let style_src = covered.or(map.shapes.len().checked_sub(1));
|
||||
let merged_fill = fill.or_else(|| covered.is_none().then_some(current_fill));
|
||||
let mut new_nodes: Vec<Node> = vec![];
|
||||
let mut merged_id = None;
|
||||
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
||||
@@ -306,7 +366,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
for k in keep {
|
||||
if let Some((l, _)) = map.lines.get(k) {
|
||||
new_nodes.push(shape_node(d, l.path.clone(), Some(leaf)));
|
||||
new_nodes.push(remnant(d, leaf, l.path.clone(), None));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +390,9 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
|
||||
};
|
||||
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
|
||||
// Edges come back from the planar map within a few hundredths of the outlines they
|
||||
// follow, whatever else is selected.
|
||||
let cut_tol = b::outline_snap(&sh.path);
|
||||
let mut cut = false;
|
||||
for piece in &cuts {
|
||||
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
|
||||
@@ -340,12 +403,13 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !in_union && !cut {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else if !rest.is_empty() {
|
||||
new_nodes.push(shape_node(d, rest, Some(leaf)));
|
||||
// What is left of its own outline keeps its fill rule; united faces fill with non-zero.
|
||||
new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero)));
|
||||
}
|
||||
}
|
||||
if !erase && !union.is_empty() && Some(i) == style_src {
|
||||
let mut m = shape_node(d, union.clone(), Some(leaf));
|
||||
if let Some(f) = &merged_fill {
|
||||
if let Some(f) = &fill {
|
||||
m.appearance.set_fill(f.clone());
|
||||
}
|
||||
merged_id = Some(m.id);
|
||||
@@ -354,15 +418,19 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new_ids = replace_roots(d, &roots, new_nodes)?;
|
||||
sel.set(new_ids.iter().copied());
|
||||
Ok((new_ids, merged_id, touched))
|
||||
Ok((new_ids, merged_id, touched, None))
|
||||
})?;
|
||||
Ok(json!({
|
||||
let mut out = json!({
|
||||
"ids": ids_json(&ids),
|
||||
"merged": merged.map(|m| m.0),
|
||||
"regions": touched.regions.len(),
|
||||
"lines": touched.lines.len(),
|
||||
"edges": touched.edges.len(),
|
||||
}))
|
||||
});
|
||||
if let Some(e) = split {
|
||||
out["split"] = json!(e.0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||
@@ -381,10 +449,32 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// What is left of path or compound path `src`: a copy of it with a fresh id and the geometry `path`
|
||||
/// (a path for one subpath, else a compound path, as [`shape_node`] makes them). It keeps the rest
|
||||
/// of `src`: its look, with gradients fitted to its bounds kept where they were on it, its name,
|
||||
/// opacity mask and attributes, and its fill rule unless `rule` is given.
|
||||
fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option<FillRule>) -> Node {
|
||||
let own = match &src.kind {
|
||||
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
let shaped = shape_node(d, path, None);
|
||||
let mut n = src.clone();
|
||||
n.pin_gradients();
|
||||
n.id = shaped.id;
|
||||
n.kind = shaped.kind;
|
||||
if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut n.kind {
|
||||
*r = rule.unwrap_or(own);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?;
|
||||
check_numbers(p, &["gap"], "shapeBuilder.regions")?;
|
||||
let roots = root_ids(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p));
|
||||
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p));
|
||||
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
|
||||
let regions: Vec<Value> = map
|
||||
.regions
|
||||
@@ -714,7 +804,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
||||
None => None,
|
||||
};
|
||||
let close = bool_or(p, "closeGaps", false);
|
||||
let close = switch_or(p, "closeGaps", false, C)?;
|
||||
let targets = live_targets(s, p)?;
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
@@ -801,11 +891,11 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0));
|
||||
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
|
||||
if !(0.0..=1000.0).contains(&width) {
|
||||
return Err(bad(C, "width out of range"));
|
||||
}
|
||||
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0);
|
||||
let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
|
||||
let edge = s.edit("Live Paint Bucket", |d, _| {
|
||||
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
|
||||
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
|
||||
@@ -863,7 +953,7 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
|
||||
|
||||
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
|
||||
let name = str_param(p, "preset").unwrap_or("Default");
|
||||
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
|
||||
let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?;
|
||||
// (A preset without an enhanced version is traced as it is.)
|
||||
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
|
||||
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
@@ -949,7 +1039,7 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
|
||||
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
|
||||
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
|
||||
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
|
||||
cmd!(
|
||||
"shape.arc",
|
||||
"Arc",
|
||||
[],
|
||||
None,
|
||||
"{x1, y1, x2, y2, closed?, slope?} or {x, y, lengthX, lengthY, reference?, baseAlong?: x|y, slope?, closed?, fillArc?} → {id}",
|
||||
has_doc,
|
||||
arc
|
||||
),
|
||||
cmd!(
|
||||
"shape.rectangularGrid",
|
||||
"Rectangular Grid",
|
||||
@@ -207,6 +215,7 @@ fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
|
||||
}
|
||||
|
||||
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["radius"], "shape.rectangle")?;
|
||||
let r = rect_of(p, "shape.rectangle")?;
|
||||
let radius = f64_or(p, "radius", 0.0).max(0.0);
|
||||
let live = LiveShape::Rectangle {
|
||||
@@ -230,6 +239,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
|
||||
let r = f64_req(p, "radius", "shape.polygon")?.abs();
|
||||
check_numbers(p, &["sides", "rotation"], "shape.polygon")?;
|
||||
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
|
||||
let rot = f64_or(p, "rotation", 0.0);
|
||||
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
|
||||
@@ -240,6 +250,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
|
||||
let r1 = f64_req(p, "radius1", "shape.star")?.abs();
|
||||
check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?;
|
||||
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
|
||||
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
|
||||
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
|
||||
@@ -251,6 +262,24 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?);
|
||||
let numbers = [
|
||||
"diameter",
|
||||
"opacity",
|
||||
"brightness",
|
||||
"growth",
|
||||
"fuzziness",
|
||||
"rays",
|
||||
"longest",
|
||||
"rayFuzziness",
|
||||
"rings",
|
||||
"largest",
|
||||
"x2",
|
||||
"y2",
|
||||
"direction",
|
||||
"pathLength",
|
||||
"seed",
|
||||
];
|
||||
check_numbers(p, &numbers, "shape.flare")?;
|
||||
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
|
||||
let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5);
|
||||
let opacity = pct("opacity", 50.0).min(1.0) as f32;
|
||||
@@ -279,7 +308,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
|
||||
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
|
||||
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops });
|
||||
let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops));
|
||||
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(g))
|
||||
};
|
||||
@@ -360,20 +389,46 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
|
||||
check_numbers(p, &["segments"], "shape.spiral")?;
|
||||
let path = shapes::spiral(
|
||||
c,
|
||||
f64_req(p, "radius", "shape.spiral")?,
|
||||
f64_or(p, "decay", 80.0),
|
||||
number_or(p, "decay", 80.0, "shape.spiral")?,
|
||||
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
|
||||
bool_or(p, "clockwise", true),
|
||||
switch_or(p, "clockwise", true, "shape.spiral")?,
|
||||
);
|
||||
add_art(s, "Spiral", path_kind(path, None), None)
|
||||
}
|
||||
|
||||
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
|
||||
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
|
||||
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
|
||||
let closed = switch_or(p, "closed", false, "shape.arc")?;
|
||||
let fill_arc = switch_or(p, "fillArc", false, "shape.arc")?;
|
||||
let base_x = !matches!(p.get("baseAlong").and_then(Value::as_str), Some("y" | "Y"));
|
||||
let path = if p.get("lengthX").is_some() || p.get("lengthY").is_some() {
|
||||
let anchor = Point::new(f64_req(p, "x", "shape.arc")?, f64_req(p, "y", "shape.arc")?);
|
||||
let reference = p.get("reference").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
shapes::arc_segment(
|
||||
anchor,
|
||||
reference,
|
||||
number_or(p, "lengthX", 100.0, "shape.arc")?,
|
||||
number_or(p, "lengthY", 100.0, "shape.arc")?,
|
||||
base_x,
|
||||
number_or(p, "slope", 50.0, "shape.arc")?,
|
||||
closed,
|
||||
)
|
||||
} else {
|
||||
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
|
||||
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
|
||||
let slope = number_or(p, "slope", 100.0, "shape.arc")?;
|
||||
shapes::arc(a, b, slope, closed, base_x)
|
||||
};
|
||||
let fill = if fill_arc { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
|
||||
let look = s.new_art_look(
|
||||
fill,
|
||||
if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() },
|
||||
s.paint.stroke_width.max(0.1),
|
||||
);
|
||||
add_look(s, "Arc", path_kind(path, None), look, None)
|
||||
}
|
||||
|
||||
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
|
||||
@@ -406,9 +461,11 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) ->
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
|
||||
/// ±[`shapes::GRID_SKEW_MAX`]).
|
||||
/// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON
|
||||
/// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when
|
||||
/// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]).
|
||||
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
|
||||
check_numbers(p, &[count], cmd)?;
|
||||
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
|
||||
let skew = match p.get(key) {
|
||||
None | Some(Value::Null) => 0.0,
|
||||
@@ -466,6 +523,8 @@ pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result<Anchor> {
|
||||
}
|
||||
|
||||
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Checked with `d` too, where the path data alone decides whether the path is closed.
|
||||
let closed = switch_or(p, "closed", false, "path.create")?;
|
||||
let path = if let Some(d) = str_param(p, "d") {
|
||||
let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?;
|
||||
PathData::from_bezpath(&bp)
|
||||
@@ -480,7 +539,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if anchors.is_empty() {
|
||||
return Err(bad("path.create", "need at least one anchor"));
|
||||
}
|
||||
PathData::single(SubPath::new(anchors, bool_or(p, "closed", false)))
|
||||
PathData::single(SubPath::new(anchors, closed))
|
||||
};
|
||||
let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
|
||||
@@ -495,6 +554,11 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let x = f64_req(p, "x", "text.create")?;
|
||||
let y = f64_req(p, "y", "text.create")?;
|
||||
super::paint::check_keep_model(p, "text.create")?;
|
||||
check_numbers(p, &["size"], "text.create")?;
|
||||
check_switches(p, &["vertical", "placeholder"], "text.create")?;
|
||||
if let Some(a) = p.get("area") {
|
||||
check_numbers(a, &["width", "height"], "text.create")?;
|
||||
}
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
|
||||
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, ColorMode, Dash, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -227,15 +227,64 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> {
|
||||
next.moved = None;
|
||||
}
|
||||
}
|
||||
if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) {
|
||||
// A new document colour mode (Document Color Mode) redraws every dimension in it.
|
||||
if next != old
|
||||
|| before.color_mode != doc.color_mode
|
||||
|| before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next)
|
||||
{
|
||||
replace(doc, id, next)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn paint(c: [u8; 3]) -> Paint {
|
||||
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
|
||||
/// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved
|
||||
/// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension
|
||||
/// that can't be redrawn keeps the art it has.
|
||||
pub(crate) fn conform(doc: &mut Document) {
|
||||
if !doc.dimensions {
|
||||
return;
|
||||
}
|
||||
let mode = doc.color_mode;
|
||||
let off_mode = |c: &Color| match mode {
|
||||
ColorMode::Rgb => matches!(c, Color::Cmyk { .. }),
|
||||
ColorMode::Cmyk => matches!(c, Color::Rgb { .. }),
|
||||
};
|
||||
let mut stale = Vec::new();
|
||||
doc.walk(|n| {
|
||||
if let Some(d) = &n.extras().dimension {
|
||||
let mut off = false;
|
||||
n.walk(&mut |c| {
|
||||
off |= [c.appearance.fill_paint(), c.appearance.stroke_paint()].iter().filter_map(|p| p.color()).any(|c| off_mode(&c));
|
||||
// The label's characters carry their own fill.
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
off |= t.runs.iter().filter_map(|r| r.style.fill.color()).any(|c| off_mode(&c));
|
||||
}
|
||||
});
|
||||
if off {
|
||||
stale.push((n.id, (**d).clone()));
|
||||
}
|
||||
}
|
||||
});
|
||||
for (id, d) in stale {
|
||||
// An error leaves this dimension as it was; the others are still redrawn.
|
||||
let _ = replace(doc, id, d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Option colour `c` as a paint in the document's colour model: in a CMYK document black is K
|
||||
/// only (a rich four-ink black would print a black dimension in every plate) and other colours
|
||||
/// convert through the active colour settings.
|
||||
fn paint(c: [u8; 3], mode: ColorMode) -> Paint {
|
||||
let rgb = Color::rgb8(c[0], c[1], c[2]);
|
||||
Paint::solid(match mode {
|
||||
ColorMode::Rgb => rgb,
|
||||
ColorMode::Cmyk if c == [0, 0, 0] => Color::cmyk(0.0, 0.0, 0.0, 1.0),
|
||||
ColorMode::Cmyk => {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
cms.convert(&rgb, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent)
|
||||
}
|
||||
})
|
||||
}
|
||||
fn line(a: Point, b: Point) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
@@ -244,10 +293,10 @@ fn line(a: Point, b: Point) -> BezPath {
|
||||
p
|
||||
}
|
||||
|
||||
fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance {
|
||||
fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> Appearance {
|
||||
let (color, width, kind) =
|
||||
if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) };
|
||||
let mut a = Appearance::basic(Paint::None, paint(color), width);
|
||||
let mut a = Appearance::basic(Paint::None, paint(color, mode), width);
|
||||
if let Some(st) = a.stroke_mut() {
|
||||
st.dash = match kind {
|
||||
LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }),
|
||||
@@ -285,6 +334,7 @@ fn along(o: &DimensionOptions) -> f64 {
|
||||
fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<Node>>> {
|
||||
d.validate().map_err(error)?;
|
||||
let o = &d.options;
|
||||
let mode = doc.color_mode;
|
||||
let label = d.label().map_err(error)?;
|
||||
let mut paths: Vec<(BezPath, Appearance)> = Vec::new();
|
||||
let mut angle = 0.0;
|
||||
@@ -301,11 +351,11 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
for i in 1..=64 {
|
||||
arc.line_to(on(i as f64 / 64.0));
|
||||
}
|
||||
paths.push((arc, appearance(o, false, true)));
|
||||
paths.push((arc, appearance(o, mode, false, true)));
|
||||
if !o.hide_extensions {
|
||||
for p in [d.a, d.b] {
|
||||
let dir = (p - d.vertex) / (p - d.vertex).hypot();
|
||||
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, true, false)));
|
||||
paths.push((line(d.vertex + dir * o.extension_offset, d.vertex + dir * (r + o.extension_length)), appearance(o, mode, true, false)));
|
||||
}
|
||||
}
|
||||
let dir = Vec2::from_angle(start + sweep * along(o));
|
||||
@@ -333,7 +383,7 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
};
|
||||
if d.mode == DimensionMode::Linear && !o.hide_extensions {
|
||||
for (p, q) in [(d.a, a), (d.b, b)] {
|
||||
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, true, false)));
|
||||
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, mode, true, false)));
|
||||
}
|
||||
}
|
||||
angle = u.atan2();
|
||||
@@ -342,13 +392,13 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
}
|
||||
let shift = o.text_offset + o.font_size * 0.5;
|
||||
text_at = a.lerp(b, along(o)) + outward * if away { shift } else { -shift };
|
||||
paths.push((line(a, b), appearance(o, false, true)));
|
||||
paths.push((line(a, b), appearance(o, mode, false, true)));
|
||||
}
|
||||
let style = CharStyle {
|
||||
font_family: o.font_family.clone(),
|
||||
font_style: o.font_style.clone(),
|
||||
size: o.font_size,
|
||||
fill: paint(o.text_color),
|
||||
fill: paint(o.text_color, mode),
|
||||
..Default::default()
|
||||
};
|
||||
let mut text = TextObject::point(Point::ZERO, &label, style);
|
||||
@@ -366,7 +416,7 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
}
|
||||
let id = old.get(children.len()).copied().unwrap_or_else(|| doc.alloc_id());
|
||||
let mut node = Node::new(id, NodeKind::Text(Box::new(text)));
|
||||
node.appearance = Appearance::basic(paint(o.text_color), Paint::None, 0.0).into();
|
||||
node.appearance = Appearance::basic(paint(o.text_color, mode), Paint::None, 0.0).into();
|
||||
children.push(Arc::new(node));
|
||||
Ok(children)
|
||||
}
|
||||
@@ -665,9 +665,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pressure = stroke_pressure(&samples);
|
||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
|
||||
let closed = bool_or(p, "closed", false);
|
||||
let closed = switch_or(p, "closed", false, C)?;
|
||||
let brush = str_param(p, "style") == Some("brush");
|
||||
let keep_selected = bool_or(p, "keepSelected", true);
|
||||
let keep_selected = switch_or(p, "keepSelected", true, C)?;
|
||||
let filled = switch_or(p, "fill", false, C)?;
|
||||
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
|
||||
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
|
||||
let at_start = str_param(ext, "end") == Some("start");
|
||||
@@ -764,7 +765,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "need at least two points"));
|
||||
}
|
||||
let sp = fit_freehand(&pts, tol, closed);
|
||||
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
|
||||
let fill = if filled { s.paint.fill.clone() } else { Paint::None };
|
||||
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
|
||||
// gets black. A brush is its stroke.
|
||||
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
|
||||
@@ -780,12 +781,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.curvature";
|
||||
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
|
||||
let pts: Vec<(Point, bool)> =
|
||||
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
|
||||
let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::<Result<_>>()?;
|
||||
if pts.is_empty() {
|
||||
return Err(bad(C, "need at least one point"));
|
||||
}
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false));
|
||||
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?);
|
||||
if let Some(id) = id_param(p, "id") {
|
||||
s.edit("Curvature", |d, sel| {
|
||||
*path_mut(d, id)? = PathData::single(sp);
|
||||
@@ -831,7 +831,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => EndCurve::Keep,
|
||||
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
|
||||
};
|
||||
let corner = bool_or(p, "corner", false);
|
||||
let corner = switch_or(p, "corner", false, C)?;
|
||||
let op = match str_param(p, "op") {
|
||||
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
|
||||
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
|
||||
@@ -1030,7 +1030,7 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
|
||||
return Err(bad(C, "invalid delta"));
|
||||
}
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
let perpendicular = switch_or(p, "perpendicular", false, C)?;
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -1490,11 +1490,12 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Paint::solid(Color::BLACK)
|
||||
};
|
||||
let keep_selected = bool_or(p, "keepSelected", false);
|
||||
let only_selection = bool_or(p, "mergeOnlySelection", false);
|
||||
let keep_selected = switch_or(p, "keepSelected", false, C)?;
|
||||
let only_selection = switch_or(p, "mergeOnlySelection", false, C)?;
|
||||
let merge = switch_or(p, "merge", true, C)?;
|
||||
let st = s.doc()?;
|
||||
let mut merge_ids = vec![];
|
||||
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) {
|
||||
if merge && !(only_selection && st.selection.is_empty()) {
|
||||
let all = Selection::default();
|
||||
let from = if only_selection { &st.selection } else { &all };
|
||||
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices;
|
||||
use super::*;
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
/// Register editing commands, including document-to-document artwork drops and their parameter contracts.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -73,6 +74,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
global_rulers
|
||||
),
|
||||
cmd!(
|
||||
"edit.dropSelection",
|
||||
"Drop Artwork",
|
||||
[],
|
||||
None,
|
||||
"{sourceDocument: uid, ids: [id…], grab: [x,y], at: [x,y]} copy 1–10000 editable source objects from an open document to the active document, preserving the grab point and clipboard, as one undo step",
|
||||
has_doc,
|
||||
drop_selection
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
@@ -571,6 +581,40 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Document ids scope node ids: two tabs may contain objects with the same node id.
|
||||
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.dropSelection";
|
||||
let key = "sourceDocument";
|
||||
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
|
||||
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
|
||||
let grab = point("grab")?;
|
||||
let at = point("at")?;
|
||||
let source = s.documents().iter().find(|d| d.uid == uid).ok_or_else(|| bad(CMD, "that document is no longer open"))?;
|
||||
let key = "ids";
|
||||
let values = p[key].as_array().ok_or_else(|| bad(CMD, format!("{key} must be an array of non-negative integer object ids")))?;
|
||||
if values.is_empty() || values.len() > 10000 {
|
||||
return Err(bad(CMD, "ids must contain 1–10000 object ids"));
|
||||
}
|
||||
let ids = values
|
||||
.iter()
|
||||
.map(|value| value.as_u64().map(NodeId).ok_or_else(|| bad(CMD, format!("invalid object id {value}: expected a non-negative integer"))))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// Validate the entire batch in one walk; node ids are local to the source document.
|
||||
let paths = source.doc.index_paths(ids.iter().copied());
|
||||
if let Some(id) = ids.iter().find(|id| !paths.contains_key(id)) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
}
|
||||
let clip = Clipboard::copy(source, &roots_of(&source.doc, ids));
|
||||
let bounds = clip.bounds().ok_or_else(|| bad(CMD, "the art has no bounds"))?;
|
||||
let center = bounds.center() + Vec2::new(at[0] - grab[0], at[1] - grab[1]);
|
||||
if !center.x.is_finite() || !center.y.is_finite() {
|
||||
return Err(bad(CMD, "result would exceed the canvas (coordinates out of range)"));
|
||||
}
|
||||
// A drop preserves the source colours even when the flyer has same-named swatches.
|
||||
let choices = SwatchChoices::parse(PasteMode::Drag.label(), &json!({"swatchConflict": "add"}))?;
|
||||
paste_clip(s, &json!({"center": [center.x, center.y]}), PasteMode::Drag, &clip, &choices)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum PasteMode {
|
||||
Offset,
|
||||
@@ -580,9 +624,11 @@ enum PasteMode {
|
||||
AllArtboards,
|
||||
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
|
||||
Library,
|
||||
Drag,
|
||||
}
|
||||
|
||||
impl PasteMode {
|
||||
/// Return the user-visible action label for this insertion mode.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
PasteMode::Front => "Paste in Front",
|
||||
@@ -591,6 +637,7 @@ impl PasteMode {
|
||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||
PasteMode::Offset => "Paste",
|
||||
PasteMode::Library => "Place from Library",
|
||||
PasteMode::Drag => "Drop Artwork",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,6 +670,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
|
||||
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
|
||||
let off = s.prefs.paste_offset;
|
||||
let st = s.doc()?;
|
||||
@@ -640,7 +688,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
|
||||
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
|
||||
// always goes back into its layers.
|
||||
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// A drop goes into the current editable insertion context, not a same-named source layer.
|
||||
let remember = mode != PasteMode::Drag
|
||||
&& (st.doc.paste_remembers_layers || boards.is_some())
|
||||
&& parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
|
||||
// Front/back: relative to the selection (top-most / bottom-most selected object); with
|
||||
// nothing selected, the top / bottom of the current layer.
|
||||
let anchor = match mode {
|
||||
@@ -653,7 +704,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, boards) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
(PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||
}],
|
||||
@@ -680,7 +731,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let mut c = d.reid(n);
|
||||
imported.apply(&mut c);
|
||||
if xf != Affine::IDENTITY {
|
||||
c.transform(xf, false);
|
||||
// A cross-document drag carries the graphic's appearance with it,
|
||||
// including the pattern phase, independently of transform-tool settings.
|
||||
c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() });
|
||||
}
|
||||
let layer = match clip.source_layers.get(k) {
|
||||
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
|
||||
|
||||
@@ -630,11 +630,11 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_transform_art(&v) {
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else {
|
||||
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
|
||||
// them with a gradient): through their outlines.
|
||||
// them with a gradient or changes their fills and strokes): through their outlines.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -1151,7 +1151,7 @@ mod tests {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
let (mut s, id) = session_with_rect();
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
s.edit("gradient", |d, _| {
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.set_fill(ramp);
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_color::cms::Model;
|
||||
use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
||||
use vectorcraft_doc::live::GradientMesh;
|
||||
use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||
@@ -736,7 +736,7 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
|
||||
});
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
|
||||
Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32))
|
||||
}
|
||||
|
||||
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
|
||||
@@ -745,8 +745,13 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
|
||||
/// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the
|
||||
/// canvas draws it.
|
||||
fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> {
|
||||
let at = |t: f64| (t, g.sample_with(t as f32, lerp_color));
|
||||
let at = |t: f64| (t, g.sample_keep_model(t as f32));
|
||||
let mut v = vec![at(t0)];
|
||||
// A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops
|
||||
// its expansion adds, so the linear mix between columns follows that curve.
|
||||
if !g.method.is_classic() {
|
||||
v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at));
|
||||
}
|
||||
for (i, s) in g.stops.iter().enumerate() {
|
||||
let o = s.offset as f64;
|
||||
if o > t0 && o < t1 {
|
||||
@@ -844,7 +849,7 @@ impl Frame {
|
||||
/// is just its band, so nothing doubles up.
|
||||
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
|
||||
let step = (self.t1 - self.t0) / n as f64;
|
||||
let color = |k: usize| g.sample_with((self.t0 + (k as f64 + 0.5) * step) as f32, lerp_color);
|
||||
let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32);
|
||||
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
||||
if self.radial {
|
||||
let ellipse = |t: f64| {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,8 +19,15 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
let expanded = expand_presets(s, "document.serialize", p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let doc = &*source(s, f, &p, "document.serialize")?;
|
||||
let enc = encode_all(doc, f.id, &p)?;
|
||||
let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
|
||||
let doc = &*doc;
|
||||
let mut q = p.clone();
|
||||
if f.id == "pdf"
|
||||
&& let Some(o) = q.as_object_mut()
|
||||
{
|
||||
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||
}
|
||||
let enc = encode_all(doc, f.id, &q)?;
|
||||
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
|
||||
let (main, linked) = files.split_at(enc.files.len());
|
||||
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
|
||||
@@ -53,8 +60,15 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.export", str_param(p, "format"), path)?;
|
||||
let expanded = expand_presets(s, "document.export", p)?;
|
||||
let p = with_black_output(s, &expanded);
|
||||
let doc = &*source(s, f, &p, "document.export")?;
|
||||
let enc = encode_all(doc, f.id, &p)?;
|
||||
let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
|
||||
let doc = &*doc;
|
||||
let mut q = p.clone();
|
||||
if f.id == "pdf"
|
||||
&& let Some(o) = q.as_object_mut()
|
||||
{
|
||||
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||
}
|
||||
let enc = encode_all(doc, f.id, &q)?;
|
||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
||||
}
|
||||
|
||||
@@ -154,13 +168,16 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va
|
||||
|
||||
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||
/// with `selectionOnly` the selected objects alone.
|
||||
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
|
||||
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> {
|
||||
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
|
||||
return Ok(Cow::Owned(selection(s, cmd)?.0));
|
||||
let doc = selection(s, cmd)?.0;
|
||||
let display_doc_title = super::has_own_title(&doc);
|
||||
return Ok((Cow::Owned(doc), display_doc_title));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let doc = export_source(st, p)?;
|
||||
Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned))
|
||||
let display_doc_title = super::has_own_title(&doc);
|
||||
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
|
||||
}
|
||||
|
||||
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
|
||||
|
||||
@@ -370,7 +370,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
|
||||
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
|
||||
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
|
||||
let update_links = s.prefs.update_links == "automatically";
|
||||
open_read(s, read_for_open(name, bytes, path, p, update_links)?)
|
||||
open_read(s, read_for_open(name, bytes, path, p, update_links, s.prefs.low_res_proxy_eps)?)
|
||||
}
|
||||
|
||||
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
|
||||
@@ -396,10 +396,10 @@ impl ReadFile {
|
||||
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
|
||||
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
|
||||
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
|
||||
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool) -> Result<ReadFile> {
|
||||
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
|
||||
let opts = LoadOptions::from_params("document.open", p)?;
|
||||
let mut loaded = load_with(name, bytes, &opts)?;
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links);
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps);
|
||||
Session::refresh_text_bounds(&mut loaded.doc);
|
||||
Ok(ReadFile { loaded, opts, path, links })
|
||||
}
|
||||
@@ -419,7 +419,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
||||
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
|
||||
));
|
||||
}
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically");
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
|
||||
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
|
||||
}
|
||||
|
||||
|
||||
@@ -793,6 +793,17 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate
|
||||
/// Library…. The library formats come first so Linux file portals (which grey out files that don't
|
||||
/// match the default filter) still list `.ase` and the other library extensions; then the same
|
||||
/// filters as [`open_filters`] so a document can be picked for its swatches or graphic styles.
|
||||
pub fn library_open_filters(
|
||||
library_label: &'static str,
|
||||
library_exts: &'static [&'static str],
|
||||
) -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once((library_label, library_exts)).chain(open_filters())
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
|
||||
/// RTF and Word.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
@@ -860,9 +871,12 @@ pub fn file_stem(name: &str) -> String {
|
||||
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
|
||||
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
|
||||
/// the stored title.
|
||||
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
|
||||
!doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
|
||||
let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
|
||||
let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?;
|
||||
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
|
||||
let mut d = doc.clone();
|
||||
d.title = file_stem(path);
|
||||
Some(d)
|
||||
|
||||
@@ -243,7 +243,14 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
|
||||
Ok(PdfOptions {
|
||||
settings: settings(cmd, p)?,
|
||||
artboards,
|
||||
display_doc_title: bool_or(p, "_displayDocTitle", super::has_own_title(doc)),
|
||||
created: doc.metadata.created.or(now),
|
||||
modified: now,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a PDF of some artboards carries no editing data.
|
||||
@@ -387,8 +394,15 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_preset(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
let st = s.doc()?;
|
||||
let display_doc_title = super::has_own_title(&st.doc);
|
||||
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
|
||||
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?;
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||
} else {
|
||||
q = json!({ "_displayDocTitle": display_doc_title });
|
||||
}
|
||||
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
|
||||
}
|
||||
|
||||
|
||||
@@ -800,10 +800,12 @@ fn items(
|
||||
};
|
||||
if o.full_document {
|
||||
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
|
||||
// row writes every artboard of the document itself).
|
||||
// row writes every artboard of the document itself). It is named after the document: an
|
||||
// SVG of one artboard takes the artboard's name as its title (#3544), and this one stands
|
||||
// for the whole document.
|
||||
let (whole, region) = if crop {
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||||
(Arc::new(single_artboard(doc, bounds, "Document")), bounds)
|
||||
(Arc::new(single_artboard(doc, bounds, &doc.title)), bounds)
|
||||
} else {
|
||||
(doc.clone(), Rect::ZERO)
|
||||
};
|
||||
|
||||
@@ -179,9 +179,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded { warnings, ..Encoded::default() };
|
||||
for artboard in boards {
|
||||
// A chosen artboard's file holds the art over it (#550).
|
||||
// A chosen artboard's file holds the art over it (#550) and is named after it (#3544).
|
||||
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
|
||||
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref());
|
||||
let file = ExportOptions { artboard, artboard_title: chosen, ..opts.clone() };
|
||||
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &file, native.as_deref());
|
||||
let bytes = out.take_bytes();
|
||||
for l in out.linked {
|
||||
if !enc.linked.iter().any(|e| e.name == l.name) {
|
||||
|
||||
@@ -384,6 +384,14 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_open_filters_list_library_formats_first() {
|
||||
let filters: Vec<_> = library_open_filters("Swatch libraries", super::swatchlib::LIBRARY_EXTS).collect();
|
||||
assert_eq!(filters[0], ("Swatch libraries", super::swatchlib::LIBRARY_EXTS));
|
||||
assert!(filters[0].1.contains(&"ase"));
|
||||
assert_eq!(filters[1], ("All readable files", OPEN_EXTS));
|
||||
}
|
||||
|
||||
/// The extensions usage texts list for export: one per writable format, each picking that format
|
||||
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
||||
#[test]
|
||||
|
||||
@@ -125,6 +125,24 @@ fn placed_images_come_back_as_linked_files() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
|
||||
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
|
||||
/// image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||
assert!(entities(&text(&r)).is_empty() && r.get("linked").is_none(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_dxf_options_are_refused() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -156,6 +156,23 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
|
||||
assert!(l3.contains("/FlateDecode filter >> image"));
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
|
||||
/// holds the note raster exports give, once, without naming either image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap();
|
||||
let w = warnings(&r);
|
||||
assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
|
||||
assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_eps_options_are_refused_and_formats_list_eps() {
|
||||
let mut s = session(100.0, 100.0, 1);
|
||||
|
||||
@@ -95,6 +95,24 @@ fn raster_effects_are_written_as_images() {
|
||||
assert!(kinds.contains(&114), "the shadow is an image with transparency");
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of an EMF or WMF, and
|
||||
/// `warnings` holds the note raster exports give, once, without naming either image's key.
|
||||
#[test]
|
||||
fn images_that_cant_be_decoded_give_the_raster_note_once() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
|
||||
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
|
||||
assert_eq!(keys.len(), 2);
|
||||
for format in ["emf", "wmf"] {
|
||||
let r = s.execute("document.export", &json!({"format": format})).unwrap();
|
||||
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{format}: {w:?}");
|
||||
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{format}: {w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafiles_open_and_place() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() {
|
||||
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the
|
||||
/// pages of a .ai file too), and keeps 16 bits from PDF 1.5.
|
||||
#[test]
|
||||
fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() {
|
||||
let mut s = session(1);
|
||||
let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000]));
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap();
|
||||
let readable = json!({"compressText": false});
|
||||
for (cmd, p, depth) in [
|
||||
("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8),
|
||||
("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8),
|
||||
("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8),
|
||||
("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8),
|
||||
("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16),
|
||||
("document.exportPdf", json!({"compression": readable}), 16),
|
||||
] {
|
||||
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
|
||||
let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16"));
|
||||
assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}");
|
||||
assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
|
||||
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
|
||||
/// raster exports give, once.
|
||||
|
||||
@@ -89,3 +89,51 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
|
||||
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there).
|
||||
fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> {
|
||||
fn visit(n: &vectorcraft_doc::Node, out: &mut Vec<vectorcraft_doc::ImageObject>) {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
out.push((**im).clone());
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, out);
|
||||
}
|
||||
}
|
||||
let mut images = vec![];
|
||||
for l in &doc.layers {
|
||||
visit(l, &mut images);
|
||||
}
|
||||
images.iter().rev().find_map(|im| {
|
||||
let q = im.xf.inverse() * p;
|
||||
if q.x < 0.0 || q.y < 0.0 || q.x >= im.width as f64 || q.y >= im.height as f64 {
|
||||
return None;
|
||||
}
|
||||
let ink = doc.images.get(&im.key)?.sample_cmyk_object(q.x, q.y, (im.width, im.height), 1)?;
|
||||
Some(ink.map(|v| v as f32 / 255.0))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() {
|
||||
// #2160: a CMYK panel under a PNG with see-through pixels. The flattener rasterizes where the
|
||||
// image reaches; the panel there keeps its ink numbers instead of going through RGB.
|
||||
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap();
|
||||
let teal = [1.0, 0.06, 0.41, 0.03];
|
||||
s.execute("paint.setFill", &json!({"color": {"c": teal[0], "m": teal[1], "y": teal[2], "k": teal[3]}})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([0, 120, 255, if x < 20 { 0 } else { 255 }]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
s.execute("file.place", &json!({"name": "logos.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [100, 50]})).unwrap();
|
||||
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let back = vectorcraft_pdf::import(&bytes).unwrap();
|
||||
// Behind the image's see-through half: the panel's own inks, K included.
|
||||
let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels");
|
||||
assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}");
|
||||
}
|
||||
@@ -108,3 +108,39 @@ fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
|
||||
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
|
||||
}
|
||||
|
||||
/// #3544: an SVG of one artboard on its own (an `artboard` or `range`, Export for Screens, an asset)
|
||||
/// has the artboard's (or asset's) name as `<title>`; Save's SVG and Export for Screens' Full
|
||||
/// Document keep the document's.
|
||||
#[test]
|
||||
fn an_svg_of_one_artboard_is_titled_after_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"name": "Icons", "width": 24, "height": 24, "artboards": 2})).unwrap();
|
||||
for (index, name) in [(0, "arrow-left"), (1, "arrow-right")] {
|
||||
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
|
||||
}
|
||||
let square = s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 16, "height": 16})).unwrap()["id"].clone();
|
||||
let title = |svg: &str| svg.lines().find_map(|l| l.trim().strip_prefix("<title>")?.strip_suffix("</title>")).map(str::to_string);
|
||||
let serialize = |s: &mut Session, p: serde_json::Value| s.execute("document.serialize", &p).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 1}))).as_deref(), Some("arrow-right"));
|
||||
assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 0}))).as_deref(), Some("arrow-left"));
|
||||
assert_eq!(title(&serialize(&mut s, json!({"format": "svg"}))).as_deref(), Some("Icons"), "Save's SVG");
|
||||
let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap();
|
||||
let titles: Vec<_> = all["files"].as_array().unwrap().iter().map(|f| title(f["text"].as_str().unwrap())).collect();
|
||||
assert_eq!(titles, [Some("arrow-left".into()), Some("arrow-right".into())]);
|
||||
// Export for Screens: each artboard, an asset, and the Full Document.
|
||||
let screens = |s: &mut Session, p: serde_json::Value| -> Vec<Option<String>> {
|
||||
let r = s.execute("document.exportForScreens", &p).unwrap();
|
||||
r["files"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| title(&String::from_utf8(vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()).unwrap()))
|
||||
.collect()
|
||||
};
|
||||
let svg = json!([{"format": "svg"}]);
|
||||
assert_eq!(screens(&mut s, json!({"formats": svg})), [Some("arrow-left".into()), Some("arrow-right".into())]);
|
||||
let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Square"})).unwrap()["assets"][0].clone();
|
||||
assert_eq!(screens(&mut s, json!({"assets": [asset], "formats": svg})), [Some("Square".into())]);
|
||||
assert_eq!(screens(&mut s, json!({"fullDocument": true, "formats": svg})), [Some("Icons".into())]);
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
//! effects reach there is no single colour: those regions are rendered into one image per group,
|
||||
//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the
|
||||
//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more
|
||||
//! regions than it allows (all of them at 0).
|
||||
//! regions than it allows (all of them at 0). For files without transparency (PDF/X-1a, PDF/X-3)
|
||||
//! a CMYK document's images are opaque CMYK: its colours keep their inks there too.
|
||||
//!
|
||||
//! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the
|
||||
//! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken
|
||||
@@ -28,6 +29,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||
use vectorcraft_doc::cmyk::Inks;
|
||||
use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
use vectorcraft_pathops as po;
|
||||
@@ -132,8 +134,8 @@ pub struct FlattenOptions {
|
||||
pub preserve_overprints: bool,
|
||||
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
|
||||
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
|
||||
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
|
||||
/// preset option.
|
||||
/// rasterized areas clipped to their regions are opaque (in a CMYK document, images of ink
|
||||
/// amounts). `None` for ordinary flattening. Not a preset option.
|
||||
#[serde(skip)]
|
||||
pub no_transparency: Option<BTreeSet<String>>,
|
||||
}
|
||||
@@ -805,7 +807,7 @@ struct RegionFill {
|
||||
|
||||
/// A rendered image of complex regions, clipped to them or not.
|
||||
struct Raster {
|
||||
png: Vec<u8>,
|
||||
blob: ImageBlob,
|
||||
width: u32,
|
||||
height: u32,
|
||||
xf: Affine,
|
||||
@@ -945,7 +947,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
|
||||
|
||||
/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out
|
||||
/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and
|
||||
/// its `clip` reach a pixel further. Fails when the image can't be encoded.
|
||||
/// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink
|
||||
/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs.
|
||||
/// Fails when the image can't be encoded.
|
||||
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
|
||||
let mut scale = ppi / 72.0;
|
||||
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
|
||||
@@ -958,11 +962,26 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale,
|
||||
rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale,
|
||||
);
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let tmp = isolated_doc(doc, art.to_vec());
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
||||
let no_alpha = o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha;
|
||||
if no_alpha && doc.color_mode == ColorMode::Cmyk {
|
||||
// Drawn as inks over the paper: as screen colours, the file would separate them again,
|
||||
// and a CMYK colour comes back with other inks (K moves into C, M and Y).
|
||||
let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), skip_templates: true, ..Default::default() };
|
||||
let inks = r.render_region_inks(&tmp, region, scale, &opts);
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
let (width, height) = vectorcraft_render::region_pixels(region, scale);
|
||||
// Past the size of a CMYK image: drawn in screen colours, below.
|
||||
if let Some(blob) = Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)) {
|
||||
return Ok(Raster { blob, width, height, xf, clip });
|
||||
}
|
||||
}
|
||||
let mut img = r.render_region(&tmp, region, scale, !o.preserve_alpha);
|
||||
// The drawing stops early when cancelled: this image is not whole.
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
||||
if no_alpha {
|
||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||
// edge pixels too).
|
||||
for p in img.pixels.as_chunks_mut::<4>().0 {
|
||||
@@ -1002,9 +1021,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
}
|
||||
}
|
||||
}
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?;
|
||||
Ok(Raster { png, width: img.width, height: img.height, xf, clip })
|
||||
Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip })
|
||||
}
|
||||
|
||||
/// `n` at full opacity with Normal blending throughout (objects, fills and strokes).
|
||||
@@ -1115,7 +1133,7 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
||||
/// An image of `r` (in a clip group when it is clipped).
|
||||
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||
let key = unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
|
||||
d.images.insert(key.clone(), r.blob);
|
||||
let image = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),
|
||||
|
||||
@@ -101,6 +101,12 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String {
|
||||
|
||||
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
|
||||
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
art_fonts(d, &[])
|
||||
}
|
||||
|
||||
/// The fonts (family, style, version) of the type in the layers and symbols of `d` and in `art`
|
||||
/// (art read from a file, its resources in `d`).
|
||||
pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet<UsedFont> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut add = |n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
@@ -111,6 +117,9 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
for s in &d.symbols {
|
||||
s.art.walk(&mut add);
|
||||
}
|
||||
for n in art {
|
||||
n.walk(&mut add);
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
|
||||
/// automatic points it shows first, and the stops follow the points' colours.
|
||||
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
|
||||
let item = item_target(s, p, cmd)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
@@ -308,7 +308,7 @@ fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.splitLine";
|
||||
let line = index_param(p, "line", C)?;
|
||||
let segment = index_param(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5);
|
||||
let t = number_or(p, "t", 0.5, C)?;
|
||||
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
|
||||
s.select_freeform_point(Some(index));
|
||||
Ok(json!({ "index": index }))
|
||||
@@ -332,7 +332,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.get";
|
||||
let stroke = bool_or(p, "stroke", !s.fill_active);
|
||||
let stroke = switch_or(p, "stroke", !s.fill_active, C)?;
|
||||
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
|
||||
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
|
||||
let mut v = vectorcraft_tools::params::freeform_json(&f);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -105,6 +105,14 @@ fn parse_kind(k: &str) -> Parsed<GradientKind> {
|
||||
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
|
||||
}
|
||||
|
||||
/// The `method` param of `p`, if given: how the colours between the stops mix.
|
||||
fn method_param(p: &Value) -> Parsed<Option<GradientMethod>> {
|
||||
match p.get("method") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d`
|
||||
/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and
|
||||
/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps
|
||||
@@ -117,7 +125,12 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
|
||||
if d.swatch(&name).is_none() && st.get("color").is_some() {
|
||||
continue;
|
||||
}
|
||||
let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let tint = st
|
||||
.get("tint")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number", k = "tint")))
|
||||
.transpose()?;
|
||||
let tint = tint.map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
|
||||
let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else {
|
||||
continue;
|
||||
};
|
||||
@@ -219,7 +232,12 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
|
||||
}
|
||||
let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default();
|
||||
let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops);
|
||||
let mut gp = GradientPaint::new(Gradient { kind, stops });
|
||||
let mut gp = GradientPaint::new(Gradient::new(kind, stops));
|
||||
gp.gradient.dither = match g.get("dither") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or("`dither` must be true or false")?,
|
||||
};
|
||||
gp.gradient.method = method_param(g)?.unwrap_or_default();
|
||||
gp.angle = f64_or(g, "angle", 0.0);
|
||||
gp.swatch = str_param(g, "swatch").map(str::to_string);
|
||||
let geom = GeomEdit::parse(g)?;
|
||||
@@ -314,6 +332,12 @@ pub(crate) fn apply_gradient_edit_in(
|
||||
// Midpoints mirrored with their segments.
|
||||
gp.gradient.reverse();
|
||||
}
|
||||
if let Some(d) = p.get("dither").and_then(Value::as_bool) {
|
||||
gp.gradient.dither = d;
|
||||
}
|
||||
if let Some(m) = method_param(p)? {
|
||||
gp.gradient.method = m;
|
||||
}
|
||||
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
|
||||
let before = gp.geom;
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
@@ -372,7 +396,9 @@ pub(crate) fn apply_gradient_edit_in(
|
||||
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.editGradient";
|
||||
let item = item_target(s, p, C)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
|
||||
check_switches(p, &["reverse", "dither"], C)?;
|
||||
check_numbers(p, &["angle"], C)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let ids = item.targets(s, p)?;
|
||||
// How the gradient lies on a stroke (the panel's Stroke buttons).
|
||||
@@ -381,6 +407,9 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
|
||||
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
|
||||
};
|
||||
if p.get("dither").and_then(Value::as_bool).is_some() && stroke {
|
||||
return Err(bad(C, "`dither` applies to gradient fills (stroke: false)"));
|
||||
}
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
|
||||
let stored = stop_colors(s, p, p);
|
||||
@@ -554,7 +583,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let p = &p;
|
||||
let item = item_target(s, p, C)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active)?;
|
||||
let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
|
||||
let item = item.of_kind(s, !stroke);
|
||||
let targets = match item {
|
||||
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
|
||||
|
||||
@@ -870,6 +870,11 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => vec![p],
|
||||
};
|
||||
check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?;
|
||||
for q in &entries {
|
||||
check_switches(q, &["locked"], "artboard.setProps")?;
|
||||
check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?;
|
||||
}
|
||||
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
|
||||
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
|
||||
let scale_art = bool_or(p, "scaleArt", false);
|
||||
|
||||
@@ -17,16 +17,17 @@
|
||||
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to
|
||||
//! (`links.unembed`).
|
||||
//!
|
||||
//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their
|
||||
//! files the same way: checking, updating, relinking and finding them on open work alike, and they
|
||||
//! are listed. Embed (Break Link) turns one into an editable copy of its art.
|
||||
//! Placed documents ([`PlacedDocument`], VectorCraft documents and PDF or `.ai` pages placed with
|
||||
//! Link) link to their files the same way: checking, updating, relinking and finding them on open
|
||||
//! work alike, and they are listed. Embed (Break Link) turns one into an editable copy of its art.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
@@ -66,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -111,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Link Info",
|
||||
[],
|
||||
None,
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -148,21 +149,25 @@ impl Status {
|
||||
}
|
||||
|
||||
/// The image objects that show one linked file as one content (same path, same image), or the
|
||||
/// placed documents that show one artboard of it.
|
||||
/// placed documents that show one artboard or page of it.
|
||||
struct Group {
|
||||
link: LinkInfo,
|
||||
key: String,
|
||||
/// The objects in the layers (images in symbols and patterns share the content, not an id).
|
||||
ids: Vec<NodeId>,
|
||||
/// Placed documents: whether the art's bounds (not the artboard) are shown.
|
||||
document: Option<bool>,
|
||||
/// Placed documents: what they show.
|
||||
document: Option<Shown>,
|
||||
}
|
||||
|
||||
/// The linked object `n` as (link, content key, placed document's bounding).
|
||||
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
|
||||
/// What a placed document shows: whether the art's bounds (not the artboard or page) are shown,
|
||||
/// and a PDF page's box.
|
||||
type Shown = (bool, PageBox);
|
||||
|
||||
/// The linked object `n` as (link, content key, what a placed document shows).
|
||||
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<Shown>)> {
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)),
|
||||
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))),
|
||||
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some((p.bounding, p.page_box)))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -177,7 +182,7 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
|
||||
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
let mut out: Vec<Group> = vec![];
|
||||
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
|
||||
let mut add = |link: &LinkInfo, key: &str, document: Option<bool>, id: Option<NodeId>| {
|
||||
let mut add = |link: &LinkInfo, key: &str, document: Option<Shown>, id: Option<NodeId>| {
|
||||
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
|
||||
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
|
||||
out.len() - 1
|
||||
@@ -199,7 +204,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
add(l, &im.key, None, None);
|
||||
}
|
||||
});
|
||||
d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None));
|
||||
d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -282,6 +287,18 @@ fn previewing(d: &Document, key: &str) -> bool {
|
||||
d.images.get(key).is_none_or(ImageBlob::is_proxy)
|
||||
}
|
||||
|
||||
/// A linked raster image whose file is an EPS (Preferences › File Handling › Use Low Resolution
|
||||
/// Proxy for Linked EPS).
|
||||
fn linked_eps(link: &LinkInfo) -> bool {
|
||||
fileio::format_for_name(link.name()).is_some_and(|f| f.id == "eps")
|
||||
}
|
||||
|
||||
/// With `low_res_proxy_eps`, a linked EPS image that already shows its saved preview keeps it on
|
||||
/// open instead of reading the file again (Update Links still reads modified files).
|
||||
fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool {
|
||||
low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key)
|
||||
}
|
||||
|
||||
/// Does group `g` of `d` show something other than its file: an image its preview, a placed
|
||||
/// document nothing (its file's bytes are gone)?
|
||||
fn stale(d: &Document, g: &Group) -> bool {
|
||||
@@ -320,6 +337,47 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `d` for output: linked raster images that still show only their saved preview read their files
|
||||
/// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only).
|
||||
pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec<String>) {
|
||||
let mut keys = BTreeSet::new();
|
||||
d.visit_images(|_, im| {
|
||||
if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) {
|
||||
keys.insert(im.key.clone());
|
||||
}
|
||||
});
|
||||
if keys.is_empty() {
|
||||
return (Cow::Borrowed(d), vec![]);
|
||||
}
|
||||
let mut out = d.clone();
|
||||
let mut previews = BTreeSet::new();
|
||||
let all = groups(d, None);
|
||||
for key in keys {
|
||||
let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue };
|
||||
let Some(mut f) = locate(&g.link, doc_path) else {
|
||||
previews.insert(key);
|
||||
continue;
|
||||
};
|
||||
let Some(bytes) = f.read().ok() else {
|
||||
previews.insert(key);
|
||||
continue;
|
||||
};
|
||||
let Ok(img) = fileio::raster_image(&bytes) else {
|
||||
previews.insert(key);
|
||||
continue;
|
||||
};
|
||||
let preview = d.images.get(&key).map(|b| b.bytes.clone());
|
||||
let mime = fileio::format_for_name(g.link.name()).map(|f| f.mime.to_string()).unwrap_or_else(|| img.blob.mime.clone());
|
||||
out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview });
|
||||
}
|
||||
let mut warnings = vec![];
|
||||
if !previews.is_empty() {
|
||||
warnings
|
||||
.push(format!("{} linked image(s) output as their previews: their files can't be found or read (relink or update them)", previews.len()));
|
||||
}
|
||||
(Cow::Owned(out), warnings)
|
||||
}
|
||||
|
||||
/// The linked files of the open document `st` that `links.findFiles` looks for, each with the
|
||||
/// objects in the layers that show it: the files of objects `ids` whatever their status, else the
|
||||
/// files that can't be found.
|
||||
@@ -355,16 +413,17 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
||||
/// A linked file read for the objects that show it.
|
||||
enum Read {
|
||||
Image(FileRead),
|
||||
/// A VectorCraft document read for placed documents: the link to it and what they show.
|
||||
/// A VectorCraft document or a PDF or `.ai` file read for placed documents: the link to it and
|
||||
/// what they show.
|
||||
Document(LinkInfo, super::place::document::Source),
|
||||
}
|
||||
|
||||
/// The file at `f` read for group `g`: its image, or the artboard `g` shows.
|
||||
/// The file at `f` read for group `g`: its image, or the artboard or page `g` shows.
|
||||
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
|
||||
let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) };
|
||||
let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) };
|
||||
let bytes = f.read()?;
|
||||
let page = g.link.page.unwrap_or(1);
|
||||
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?;
|
||||
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?;
|
||||
Ok(Read::Document(f.link(&bytes, &g.link), src))
|
||||
}
|
||||
|
||||
@@ -554,11 +613,12 @@ impl Resolved {
|
||||
/// away from their path take the new path. A link nothing is known of whose image shows a preview
|
||||
/// (a file another app wrote kept only that) was never read: its file is read now, into the
|
||||
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
|
||||
let mut out = Resolved::default();
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g), doc_path);
|
||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||
let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d);
|
||||
match probe(&g, doc_path) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update || unread => {
|
||||
@@ -593,8 +653,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
|
||||
(Status::Ok, Some(mut f)) => {
|
||||
let preview = previewing(d, &g.key);
|
||||
// Read when the images show the preview, or when the file's details changed.
|
||||
let bytes = if preview || f.bytes.is_some() { f.read().ok() } else { None };
|
||||
if preview {
|
||||
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
|
||||
if preview && !eps_preview {
|
||||
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
|
||||
out.missing.extend(row);
|
||||
continue;
|
||||
@@ -834,13 +894,13 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut copied, mut kept) = (vec![], vec![]);
|
||||
for (ids, path) in files {
|
||||
let bytes = read_file(&path)?;
|
||||
// Images show the file; placed documents each show their artboard of it.
|
||||
// Images show the file; placed documents each show their artboard or page of it.
|
||||
let mut images = vec![];
|
||||
let mut documents: Vec<((u32, bool), Vec<NodeId>)> = vec![];
|
||||
let mut documents: Vec<((u32, bool, PageBox), Vec<NodeId>)> = vec![];
|
||||
for id in ids {
|
||||
match st.doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::PlacedDocument(pl)) => {
|
||||
let k = (pl.page(), pl.bounding);
|
||||
let k = (pl.page(), pl.bounding, pl.page_box);
|
||||
match documents.iter_mut().find(|(e, _)| *e == k) {
|
||||
Some((_, ids)) => ids.push(id),
|
||||
None => documents.push((k, vec![id])),
|
||||
@@ -852,8 +912,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Read before it is copied: a file that can't be shown isn't copied.
|
||||
let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||
let mut sources = vec![];
|
||||
for ((page, bounding), ids) in documents {
|
||||
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?));
|
||||
for ((page, bounding, page_box), ids) in documents {
|
||||
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
|
||||
}
|
||||
let to = match ©_to {
|
||||
Some(dir) => {
|
||||
@@ -997,10 +1057,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str {
|
||||
.map_or("Image", |f| f.label)
|
||||
}
|
||||
|
||||
/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size
|
||||
/// (`pageWidth`, `pageHeight`, pt) instead of pixels.
|
||||
/// A placed document's `links.list` fields: `format` (`VectorCraft`, or the PDF or `.ai` file's
|
||||
/// format), `document: true` and its artboard's or page's size (`pageWidth`, `pageHeight`, pt)
|
||||
/// instead of pixels.
|
||||
fn document_row(p: &PlacedDocument) -> Value {
|
||||
json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height })
|
||||
let pdf = fileio::format_for_name(p.link.name()).filter(|f| matches!(f.id, "pdf" | "ai" | "ait"));
|
||||
json!({ "format": pdf.map_or("VectorCraft", |f| f.label), "document": true, "pageWidth": p.width, "pageHeight": p.height })
|
||||
}
|
||||
|
||||
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::live::{
|
||||
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance,
|
||||
PreserveShape, Spine,
|
||||
self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions,
|
||||
GradientMesh, MeshAppearance, PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Blend"],
|
||||
Some("Cmd+Alt+B"),
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||
has_doc,
|
||||
blend_make
|
||||
),
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Options…",
|
||||
["Object", "Blend"],
|
||||
None,
|
||||
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
|
||||
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}",
|
||||
always,
|
||||
blend_options
|
||||
),
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Info",
|
||||
[],
|
||||
None,
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
always,
|
||||
blend_info
|
||||
),
|
||||
@@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend panel Type and Ramp, validated before any document mutation.
|
||||
fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result<BlendEase> {
|
||||
let kind = match p.get("easingType").filter(|v| !v.is_null()) {
|
||||
None => current.kind,
|
||||
Some(v) => match v.as_str() {
|
||||
Some("easeIn") => BlendEaseType::In,
|
||||
Some("easeOut") => BlendEaseType::Out,
|
||||
Some("easeInOut") => BlendEaseType::InOut,
|
||||
Some("easeOutIn") => BlendEaseType::OutIn,
|
||||
_ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")),
|
||||
},
|
||||
};
|
||||
Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 })
|
||||
}
|
||||
|
||||
/// Bounded percentage input; missing or null preserves the current value.
|
||||
fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result<f64> {
|
||||
match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(current),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && (min..=100.0).contains(n))
|
||||
.ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
|
||||
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
|
||||
const C: &str = "object.blend.make";
|
||||
@@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
|
||||
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
|
||||
_ => Default::default(),
|
||||
};
|
||||
let ease = ease_param(p, C, ease0)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
|
||||
let id = s.edit("Make Blend", |d, sel| {
|
||||
wrap(d, sel, &roots, |id, nodes| {
|
||||
let (children, starts) = merge_keys(nodes, &starts);
|
||||
@@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.knockout = vectorcraft_doc::Knockout::On;
|
||||
n
|
||||
});
|
||||
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() };
|
||||
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
|
||||
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
|
||||
extend_spine(&mut spec, old, old_spec, &children);
|
||||
}
|
||||
@@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
|
||||
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.options";
|
||||
let Some((_, cur, _)) = selected_spec(s) else {
|
||||
// Nothing to set them on: the options new blends start with.
|
||||
if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "select a blend to set easing or color shift"));
|
||||
}
|
||||
// Nothing to set them on: the options new blends start with (easing is per blend).
|
||||
let d = s.prefs.blend_options.unwrap_or_default();
|
||||
let spacing = spacing_param(p, C, d.spacing)?;
|
||||
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
|
||||
@@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let ease = ease_param(p, C, cur.ease)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?;
|
||||
edit_blends(s, "Blend Options", |_, spec| {
|
||||
spec.spacing = spacing;
|
||||
spec.orientation = orientation;
|
||||
spec.ease = ease;
|
||||
spec.color_shift = color_shift;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut v = blend_options_json(spec.spacing, spec.orientation);
|
||||
v["target"] = json!("blend");
|
||||
v["id"] = json!(id.0);
|
||||
v["easingType"] = json!(spec.ease.kind.id());
|
||||
v["ramp"] = json!(spec.ease.ramp * 100.0);
|
||||
v["colorShift"] = json!(spec.color_shift);
|
||||
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
|
||||
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
|
||||
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
|
||||
|
||||
@@ -353,6 +353,7 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let random = switch_or(p, "random", false, "object.transformEach")?;
|
||||
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
||||
let copy = switch_or(p, "copy", false, "object.transformEach")?;
|
||||
check_switches(p, &["strokes", "corners", "objects", "patterns"], "object.transformEach")?;
|
||||
let objects = super::object::transforms_objects(p);
|
||||
if sh == 0.0 || sv == 0.0 {
|
||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||
@@ -1018,6 +1019,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["spacing"], CMD)?;
|
||||
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
|
||||
let move_art = switch_or(p, "moveArtwork", true, CMD)?;
|
||||
check_switches(p, &["patterns"], CMD)?;
|
||||
let locked_and_hidden = s.prefs.move_locked_with_artboard;
|
||||
let d = &s.doc()?.doc;
|
||||
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
|
||||
Loaded 100 of 393 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user