mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a33cff3f6 | ||
|
|
a067177778 | ||
|
|
d9c9526898 | ||
|
|
8c889279f8 | ||
|
|
d1108bdbca | ||
|
|
04244fa2a2 | ||
|
|
612a13b39c | ||
|
|
f1e634af19 | ||
|
|
8c14609370 | ||
|
|
b076025a31 | ||
|
|
3bb87989df | ||
|
|
a40e8f5aff | ||
|
|
1a8e0456cb | ||
|
|
7f1ad31a7b | ||
|
|
6fd31d4dbd | ||
|
|
f9b051d3d1 | ||
|
|
6bc0222074 | ||
|
|
eff2ccdc34 | ||
|
|
c73871eacc | ||
|
|
2afd2654e8 | ||
|
|
725c3881d4 | ||
|
|
aa93258ea7 | ||
|
|
fe735f0e7d | ||
|
|
65e2168791 | ||
|
|
c44ece3113 | ||
|
|
014021577b | ||
|
|
abe89d5176 | ||
|
|
0c0d2448da | ||
|
|
5a120e323b | ||
|
|
5de774835b | ||
|
|
1a280e1402 | ||
|
|
8bd8e2b298 | ||
|
|
cd1b590aa8 | ||
|
|
dcd0da87cb | ||
|
|
f774968b20 | ||
|
|
f1fd8e85e8 | ||
|
|
991ce2c635 | ||
|
|
0efcaeb558 | ||
|
|
17d2493616 | ||
|
|
3ae71c16ae | ||
|
|
ecf86ca21e | ||
|
|
886b33c584 | ||
|
|
2af6f5bfbe | ||
|
|
cde9ee8a71 | ||
|
|
e802ea8438 | ||
|
|
8f8d49110f | ||
|
|
5ecd045156 | ||
|
|
5688294d56 | ||
|
|
c348b384aa | ||
|
|
a6d5c461a1 | ||
|
|
779120cb5b | ||
|
|
2e6b925793 | ||
|
|
48041c8cd3 | ||
|
|
1d282159dd | ||
|
|
0ad92e4649 | ||
|
|
729e5ced7f | ||
|
|
0cb55bc0aa | ||
|
|
277e893e82 | ||
|
|
ea30e411e7 | ||
|
|
355bb5c4f6 | ||
|
|
7aa00b41da | ||
|
|
f3a3224745 | ||
|
|
e6bf586556 | ||
|
|
263d2f23c8 | ||
|
|
61ef472e71 | ||
|
|
4ef0cb76e6 | ||
|
|
891a58eff8 | ||
|
|
318e6b07b7 | ||
|
|
ba5447bbd9 | ||
|
|
c3a830bc38 | ||
|
|
b56400b5f9 | ||
|
|
494ce39de5 | ||
|
|
f97d57f859 | ||
|
|
a60f7e1858 | ||
|
|
8fd0612069 | ||
|
|
3f5838843d | ||
|
|
1d62d75150 | ||
|
|
7e3fc2d021 | ||
|
|
b45ceb9427 | ||
|
|
ed96b2ff65 | ||
|
|
e54fe9c31e | ||
|
|
904414aa7d | ||
|
|
ad165a5a00 | ||
|
|
8796aa9d85 | ||
|
|
225f3d7b0c | ||
|
|
0ae6c288ea | ||
|
|
b25c106fbf | ||
|
|
22118a3e8e | ||
|
|
8894da68d7 | ||
|
|
cdd656bd48 | ||
|
|
03c0b8f355 | ||
|
|
0934f97627 | ||
|
|
c10da25ce4 | ||
|
|
9c91c36e7e | ||
|
|
8ab574b7de | ||
|
|
e0ba6e77d1 | ||
|
|
9c289db91d | ||
|
|
b1a5b79d67 | ||
|
|
156fbe9912 | ||
|
|
1f4daf519d | ||
|
|
638d55983f | ||
|
|
c6d9822f26 | ||
|
|
fe83800ed5 | ||
|
|
567f5edaf3 | ||
|
|
0a6a047fd9 | ||
|
|
534f00dfd3 | ||
|
|
c5b2495291 | ||
|
|
7dd72dee58 | ||
|
|
d6b63fbc87 | ||
|
|
fb905ae911 | ||
|
|
841ea1716d | ||
|
|
7975b07b22 | ||
|
|
a1a27636e1 | ||
|
|
33a5f2a801 | ||
|
|
fdf6ba9c9d | ||
|
|
b3ac169f00 | ||
|
|
dfb0ce36bd |
No files matched your search
Generated
+61
-43
@@ -1304,6 +1304,12 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -1760,9 +1766,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-ccitt"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
@@ -1776,9 +1782,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
|
||||
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -1788,28 +1794,28 @@ dependencies = [
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.42.1",
|
||||
"skrifa 0.46.2",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"fearless_simd 1.1.0",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"fearless_simd 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1820,9 +1826,9 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -3833,6 +3839,16 @@ dependencies = [
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.46.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.43.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.47.0"
|
||||
@@ -4490,7 +4506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4525,7 +4541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4536,7 +4552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4548,7 +4564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4564,7 +4580,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4586,7 +4602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4598,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
@@ -4615,7 +4631,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4630,7 +4646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4666,7 +4682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4687,7 +4703,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4702,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4714,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4733,7 +4749,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4747,7 +4763,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4759,7 +4775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4784,11 +4800,12 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4802,7 +4819,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4827,14 +4844,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4858,7 +4875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4875,7 +4892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4895,7 +4912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4903,7 +4920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4918,7 +4935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4930,7 +4947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4959,11 +4976,12 @@ dependencies = [
|
||||
"vectorcraft-three-d",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4986,7 +5004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
"fearless_simd 0.4.1",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -5001,7 +5019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
"fearless_simd 0.4.1",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -6073,7 +6091,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -61,7 +61,9 @@ wat = "1.228"
|
||||
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
usvg = "0.48"
|
||||
krilla = "0.8"
|
||||
# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are
|
||||
# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on.
|
||||
krilla = { version = "0.8", default-features = false, features = ["raster-images"] }
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.5"
|
||||
|
||||
+10
-1
@@ -6,7 +6,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-14 · **Change:** minor (merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; 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
|
||||
@@ -125,8 +125,14 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
|
||||
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
|
||||
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
|
||||
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
|
||||
| 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number movLine truncated
|
||||
| 2026-10-13 | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565). SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596). Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568). The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598). The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601). SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567). PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585). The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584). GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570). Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590). The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560). Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616). Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571). Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel kLine truncated
|
||||
| 2026-10-13 | Control sockets close after bounded idle, partial-line and reply-write waits, returning their connection slot (#1726); existing command processing timeout remains separate. |
|
||||
@@ -162,6 +168,9 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
|
||||
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
|
||||
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
|
||||
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
|
||||
|
||||
@@ -310,7 +310,7 @@ fn notice(lines: &[&str]) {
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
|
||||
/// (libraries, presets and plug-ins open wherever they are dropped).
|
||||
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
|
||||
/// When the graphics are lost it hands the app to a new runner on a new canvas.
|
||||
struct WebShell {
|
||||
/// `None` once handed on.
|
||||
@@ -434,11 +434,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = download(name, bytes) {
|
||||
log::error!("download of {name} failed: {e}");
|
||||
}
|
||||
})),
|
||||
// A download that couldn't be started fails the save or export it is for (#1693).
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
|
||||
inbox: Some(inbox),
|
||||
recovery_store: Some(Arc::new(BrowserStore { locks })),
|
||||
// File → Print: the browser's print dialog.
|
||||
@@ -517,7 +514,8 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
web_sys::Url::revoke_object_url(&url).ok();
|
||||
});
|
||||
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
|
||||
// The download has started: a timer that can't be set leaves the URL allocated, no more.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,21 @@ pub const MAX_CONNECTIONS: usize = 16;
|
||||
/// Size in bytes of the buffer that [`write_line`] writes a reply line through.
|
||||
const REPLY_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// How long a request waits for the app's reply.
|
||||
const REPLY_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The reply to a request the app didn't answer in [`REPLY_WAIT`]. One it had not started is
|
||||
/// withdrawn, so it never runs after its caller was told it failed; one it is running can't be
|
||||
/// stopped, and the reply says its outcome isn't known.
|
||||
fn timed_out(ticket: &vectorcraft_ui_egui::ControlTicket) -> Value {
|
||||
let error = if ticket.give_up() {
|
||||
"timeout: the app did not start the request in time; it was withdrawn and will not run"
|
||||
} else {
|
||||
"timeout: the app is still running the request; it may yet complete"
|
||||
};
|
||||
json!({"ok": false, "error": error})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ConnectionTimeouts {
|
||||
idle: Duration,
|
||||
@@ -106,9 +121,10 @@ fn spawn_connection(
|
||||
let _slot = slot;
|
||||
serve_socket(stream, timeouts, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let ticket = req.ticket();
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket)))
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -455,6 +471,19 @@ mod tests {
|
||||
json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}})
|
||||
}
|
||||
|
||||
/// #1725: a request its caller gave up before the app took it never runs; one the app took
|
||||
/// first can't be withdrawn, and the timeout says so.
|
||||
#[test]
|
||||
fn a_timed_out_request_is_withdrawn_unless_it_started() {
|
||||
let (waiting, _rx) = ControlRequest::new("engine.execute", json!({}));
|
||||
assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run"));
|
||||
assert!(!waiting.claim(), "the app skips a withdrawn request");
|
||||
let (running, _rx) = ControlRequest::new("engine.execute", json!({}));
|
||||
assert!(running.claim());
|
||||
assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running"));
|
||||
assert!(!running.claim(), "a request is taken once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of
|
||||
//! every filter but doesn't show their names. The accessory uses the same filters and updates
|
||||
//! the filename, whose extension already determines the format in the save command.
|
||||
//!
|
||||
//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The
|
||||
//! panel reference is weak, so closing the sheet releases both without a retain cycle.
|
||||
//! Native view handles and Objective-C subclassing require the scoped unsafe allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use vectorcraft_ui_egui::{FilePick, i18n};
|
||||
|
||||
type Filter = (&'static str, &'static [&'static str]);
|
||||
|
||||
struct Ivars {
|
||||
panel: Weak<NSSavePanel>,
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
|
||||
#[unsafe(super = NSView)]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[ivars = Ivars]
|
||||
#[name = "VectorcraftSaveFormatAccessory"]
|
||||
struct FormatAccessory;
|
||||
|
||||
// SAFETY: NSObjectProtocol has no additional requirements.
|
||||
unsafe impl NSObjectProtocol for FormatAccessory {}
|
||||
|
||||
impl FormatAccessory {
|
||||
// SAFETY: AppKit's target/action sends the popup as the single object argument.
|
||||
#[unsafe(method(chooseFormat:))]
|
||||
fn choose_format(&self, sender: &NSPopUpButton) {
|
||||
let Some(panel) = self.ivars().panel.load() else { return };
|
||||
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
|
||||
let Some((_, extensions)) = self.ivars().filters.get(index) else { return };
|
||||
let Some(extension) = extensions.first() else { return };
|
||||
let name = panel.nameFieldStringValue().to_string();
|
||||
let new = filename(&name, extension, &self.ivars().filters);
|
||||
panel.setNameFieldStringValue(&NSString::from_str(&new));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/// The suggested file's type, or the request's default (the first filter).
|
||||
fn initial_format(name: &str, filters: &[Filter]) -> usize {
|
||||
let extension = Path::new(name).extension().and_then(|e| e.to_str());
|
||||
filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name.
|
||||
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
|
||||
if name.is_empty() {
|
||||
return name.to_owned();
|
||||
}
|
||||
let path = Path::new(name);
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x))))
|
||||
{
|
||||
path.with_extension(extension).to_string_lossy().into_owned()
|
||||
} else {
|
||||
format!("{name}.{extension}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests
|
||||
/// without several named file types are left to rfd.
|
||||
pub fn attach(window: &winit::window::Window, pick: &FilePick) {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
|
||||
if filters.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Ok(handle) = window.window_handle() else { return };
|
||||
let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return };
|
||||
// SAFETY: winit keeps its content view alive while this window reference is in use.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
|
||||
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() else { return };
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
|
||||
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
|
||||
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] };
|
||||
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
|
||||
label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0)));
|
||||
let popup =
|
||||
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false);
|
||||
for (name, _) in &accessory.ivars().filters {
|
||||
popup.addItemWithTitle(&NSString::from_str(i18n::t(name)));
|
||||
}
|
||||
// The filter list comes from the built-in format registry, small enough for NSInteger.
|
||||
if let Ok(index) = isize::try_from(selected) {
|
||||
popup.selectItemAtIndex(index);
|
||||
}
|
||||
// SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is
|
||||
// open. The selector above has exactly the target/action signature expected by NSControl.
|
||||
unsafe {
|
||||
popup.setTarget(Some(&accessory));
|
||||
popup.setAction(Some(sel!(chooseFormat:)));
|
||||
accessory.addSubview(&label);
|
||||
accessory.addSubview(&popup);
|
||||
}
|
||||
panel.setExtensionHidden(false);
|
||||
panel.setAccessoryView(Some(&accessory));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
|
||||
|
||||
#[test]
|
||||
fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() {
|
||||
for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] {
|
||||
assert_eq!(initial_format(name, FILTERS), index);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() {
|
||||
for (name, expected) in
|
||||
[("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")]
|
||||
{
|
||||
assert_eq!(filename(name, "svg", FILTERS), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,6 @@ mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_save_format;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
@@ -95,6 +93,11 @@ impl eframe::App for App {
|
||||
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
|
||||
}
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
// Preferences are written when confirmed, not only at exit: a session ended any other way
|
||||
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
|
||||
if self.app.take_prefs_changed() {
|
||||
file_access::unconfined(|| save_prefs(&self.app));
|
||||
}
|
||||
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
|
||||
@@ -480,9 +483,10 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Save(pick) => {
|
||||
PickRequest::Save(_) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
|
||||
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
|
||||
let picked = dialog.save_file();
|
||||
mac_save_format::attach(window, &pick);
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
PickRequest::Folder => {
|
||||
|
||||
@@ -296,12 +296,23 @@ const MAX_TREE_DEPTH: usize = 128;
|
||||
/// Total layer nodes imported.
|
||||
const MAX_NODES: usize = 500_000;
|
||||
|
||||
/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits
|
||||
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
|
||||
const MAX_LAYER_PIXELS: u64 = 512 << 20;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// [`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)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
Reader::new(&s, &mut archive).document()
|
||||
let mut reader = Reader::new(&s, &mut archive);
|
||||
reader.pixels_left = pixels;
|
||||
reader.document()
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
@@ -311,11 +322,13 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
|
||||
pub(crate) pixels_left: u64,
|
||||
}
|
||||
|
||||
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() }
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
|
||||
@@ -105,10 +105,17 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
return Ok(None);
|
||||
}
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
let pixels = u64::from(w) * u64::from(h);
|
||||
if pixels > MAX_PIXELS {
|
||||
r.warn("pixel layers larger than 64 megapixels");
|
||||
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 {
|
||||
r.warn("pixel layers beyond the pixels one document may hold");
|
||||
return Ok(None);
|
||||
};
|
||||
r.pixels_left = left;
|
||||
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
|
||||
let (channels, bps) = match format {
|
||||
Some(0) => (4, 1),
|
||||
@@ -404,6 +411,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile
|
||||
/// of 0xFF). Written by hand for the reader, like [`fixture`].
|
||||
fn flat_layers(count: u32, side: i32) -> Vec<u8> {
|
||||
let layer = |i: u32| {
|
||||
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))];
|
||||
for c in 1..=4 {
|
||||
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2])));
|
||||
}
|
||||
F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))])
|
||||
};
|
||||
let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]);
|
||||
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
synth::container(&[("doc.dat", &doc, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
/// #1704: the pixels of all pixel layers together are bounded, also when the archive holds
|
||||
/// no data for them: the layers past the allowance are left out with a note.
|
||||
#[test]
|
||||
fn pixel_layers_share_one_allowance_for_the_whole_document() {
|
||||
let file = flat_layers(5, 16);
|
||||
let layers = |pixels: u64| {
|
||||
let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap();
|
||||
let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count();
|
||||
(n, doc.warnings)
|
||||
};
|
||||
assert_eq!(layers(5 * 256), (5, vec![]));
|
||||
let (kept, warnings) = layers(3 * 256 + 255);
|
||||
assert_eq!(kept, 3);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}");
|
||||
assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
|
||||
let bytes = png();
|
||||
|
||||
+89
-5
@@ -16,6 +16,7 @@ mod defaults;
|
||||
pub mod track;
|
||||
mod warp;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -557,10 +558,11 @@ fn tolerance(scale: f64) -> f64 {
|
||||
(0.02 * scale).clamp(0.01, 0.25)
|
||||
}
|
||||
|
||||
/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their
|
||||
/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the
|
||||
/// caller's job (`StrokeLayer::opacity`).
|
||||
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
|
||||
/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id
|
||||
/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush,
|
||||
/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]).
|
||||
/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`).
|
||||
pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
|
||||
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
|
||||
let mut out = match &brush.kind {
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
|
||||
@@ -573,9 +575,91 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
|
||||
for n in &mut out {
|
||||
colorize(n, colorization, &stroke.paint);
|
||||
}
|
||||
leave_out_brushed(doc, &mut out, &mut Known::new());
|
||||
out
|
||||
}
|
||||
|
||||
/// A symbol (`false`) or pattern (`true`) of a document, by name: what art draws by name.
|
||||
type Named<'a> = (bool, &'a str);
|
||||
|
||||
/// What [`draws_brushed`] found of each symbol and pattern so far.
|
||||
type Known = HashMap<(bool, String), bool>;
|
||||
|
||||
/// The symbol an instance draws and the patterns that paint `n` (not its children).
|
||||
fn named(n: &Node) -> impl Iterator<Item = Named<'_>> {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
|
||||
_ => None,
|
||||
};
|
||||
let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p {
|
||||
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
|
||||
_ => None,
|
||||
});
|
||||
symbol.into_iter().chain(patterns)
|
||||
}
|
||||
|
||||
/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in
|
||||
/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far.
|
||||
fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
|
||||
let key = (def.0, def.1.to_string());
|
||||
if let Some(&answer) = known.get(&key) {
|
||||
return answer;
|
||||
}
|
||||
let art = |(pattern, name): Named<'_>| -> Vec<&Node> {
|
||||
match pattern {
|
||||
false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(),
|
||||
true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(),
|
||||
}
|
||||
};
|
||||
// Without recursion, each one looked at once: they may use one another without end.
|
||||
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
|
||||
let mut answer = false;
|
||||
while let Some(n) = stack.pop() {
|
||||
if has_brush(n) {
|
||||
answer = true;
|
||||
break;
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
|
||||
for other in named(n) {
|
||||
if seen.insert((other.0, other.1.to_string())) {
|
||||
stack.extend(art(other));
|
||||
}
|
||||
}
|
||||
}
|
||||
known.insert(key, answer);
|
||||
answer
|
||||
}
|
||||
|
||||
/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke
|
||||
/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or
|
||||
/// pattern holding art stroked with the brush itself would draw the brush again, without end.
|
||||
fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
|
||||
fn walk(doc: &Document, n: &mut Node, known: &mut Known) {
|
||||
for paint in vectorcraft_doc::swatches::node_paints_mut(n) {
|
||||
if let Paint::Pattern { pattern, .. } = paint
|
||||
&& draws_brushed(doc, (true, pattern.as_str()), known)
|
||||
{
|
||||
*paint = Paint::None;
|
||||
}
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
|
||||
for c in children.iter_mut() {
|
||||
walk(doc, Arc::make_mut(c), known);
|
||||
}
|
||||
}
|
||||
if n.extras().mask.is_some()
|
||||
&& let Some(mask) = n.extras_mut().mask.as_mut()
|
||||
{
|
||||
walk(doc, Arc::make_mut(&mut mask.art), known);
|
||||
}
|
||||
}
|
||||
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
|
||||
for n in nodes.iter_mut() {
|
||||
walk(doc, n, known);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each
|
||||
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
|
||||
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
|
||||
@@ -591,7 +675,7 @@ pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
for mut p in stroke_pieces(b, &bp, st) {
|
||||
for mut p in stroke_pieces(doc, b, &bp, st) {
|
||||
p.opacity *= st.opacity;
|
||||
if st.blend != vectorcraft_color::BlendMode::Normal {
|
||||
p.blend = st.blend;
|
||||
|
||||
+90
-38
@@ -37,7 +37,7 @@ fn all_points(n: &Node, out: &mut Vec<Point>) {
|
||||
|
||||
#[test]
|
||||
fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
|
||||
let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out.len(), 1);
|
||||
let b = bounds(&out);
|
||||
assert!(near(b.height(), 10.0, 0.05), "{b:?}");
|
||||
@@ -46,30 +46,30 @@ fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
|
||||
|
||||
#[test]
|
||||
fn calligraphic_flat_nib_is_thin_along_its_angle() {
|
||||
let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calligraphic_width_at_45_degrees_matches_ellipse_support() {
|
||||
let (a, b) = (5.0_f64, 1.0_f64);
|
||||
let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt();
|
||||
assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height());
|
||||
// A vertical stroke with the same nib has the same width (45° is symmetric).
|
||||
let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
|
||||
let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
|
||||
assert!(near(bounds(&v).width(), expect, 0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calligraphic_scales_with_stroke_weight() {
|
||||
let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
|
||||
assert!(near(bounds(&out).height(), 6.0, 0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_nib_is_direction_independent() {
|
||||
let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
|
||||
let b = bounds(&out);
|
||||
// Round caps: the bounds grow by the radius on every side.
|
||||
assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}");
|
||||
@@ -78,7 +78,7 @@ fn round_nib_is_direction_independent() {
|
||||
#[test]
|
||||
fn closed_calligraphic_makes_a_ring() {
|
||||
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
|
||||
let pd = out[0].path_data().unwrap();
|
||||
assert_eq!(pd.subpaths.len(), 2);
|
||||
let poly_area = |sp: &vectorcraft_geom::SubPath| {
|
||||
@@ -102,12 +102,14 @@ fn rect_art(w: f64, h: f64) -> Node {
|
||||
|
||||
#[test]
|
||||
fn art_brush_stretches_to_path_length() {
|
||||
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
|
||||
let out =
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
|
||||
let b = bounds(&out);
|
||||
assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}");
|
||||
assert!(near(b.height(), 4.0, 1e-6));
|
||||
// 3 pt stroke → 3× wider.
|
||||
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
|
||||
let out =
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
|
||||
assert!(near(bounds(&out).height(), 12.0, 1e-6));
|
||||
}
|
||||
|
||||
@@ -120,7 +122,7 @@ fn art_brush_arc_length_mapping_is_linear() {
|
||||
let mut spacer = frame.clone();
|
||||
spacer.appearance = Appearance::default().into();
|
||||
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
|
||||
let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
|
||||
let g = &out[0];
|
||||
let m = g.children().unwrap()[1].geometric_bounds().unwrap();
|
||||
assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}");
|
||||
@@ -128,7 +130,8 @@ fn art_brush_arc_length_mapping_is_linear() {
|
||||
|
||||
#[test]
|
||||
fn art_brush_proportional_keeps_aspect() {
|
||||
let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
let out =
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
assert!(near(bounds(&out).height(), 8.0, 1e-6));
|
||||
}
|
||||
|
||||
@@ -139,7 +142,12 @@ fn art_brush_between_guides_keeps_ends() {
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let shaft = rect_art(80.0, 2.0);
|
||||
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]);
|
||||
let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(
|
||||
&Document::new(1.0, 1.0),
|
||||
&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }),
|
||||
&line(0.0, 0.0, 300.0, 0.0),
|
||||
&stroke(1.0),
|
||||
);
|
||||
let h = out[0].children().unwrap()[1].geometric_bounds().unwrap();
|
||||
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
|
||||
}
|
||||
@@ -154,7 +162,7 @@ fn art_brush_follows_curves() {
|
||||
bp_open.push(*el);
|
||||
}
|
||||
}
|
||||
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
|
||||
let mut pts = vec![];
|
||||
all_points(&out[0], &mut pts);
|
||||
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
|
||||
@@ -178,13 +186,13 @@ fn scatter_brush(spacing: f64) -> Brush {
|
||||
|
||||
#[test]
|
||||
fn scatter_count_and_spacing() {
|
||||
let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out.len(), 10);
|
||||
let centres: Vec<f64> = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect();
|
||||
for (i, c) in centres.iter().enumerate() {
|
||||
assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}");
|
||||
}
|
||||
let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out.len(), 5);
|
||||
}
|
||||
|
||||
@@ -201,8 +209,8 @@ fn scatter_is_deterministic_and_randomised() {
|
||||
}),
|
||||
};
|
||||
let p = line(0.0, 0.0, 300.0, 0.0);
|
||||
let a1 = stroke_pieces(&b, &p, &stroke(1.0));
|
||||
let a2 = stroke_pieces(&b, &p, &stroke(1.0));
|
||||
let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
|
||||
let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
|
||||
assert_eq!(a1, a2);
|
||||
let ys: Vec<f64> = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect();
|
||||
assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}");
|
||||
@@ -228,21 +236,21 @@ fn pattern_brush(fit: PatternFit, corners: bool) -> Brush {
|
||||
#[test]
|
||||
fn pattern_tile_count_by_fit() {
|
||||
let p = line(0.0, 0.0, 100.0, 0.0);
|
||||
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
|
||||
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
|
||||
let p2 = line(0.0, 0.0, 104.0, 0.0);
|
||||
let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
|
||||
let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
|
||||
assert_eq!(st.len(), 10);
|
||||
assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly");
|
||||
let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
|
||||
let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(sp.len(), 10);
|
||||
assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size");
|
||||
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
|
||||
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_places_corner_tiles() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
|
||||
let corners = out
|
||||
.iter()
|
||||
.filter(
|
||||
@@ -257,7 +265,7 @@ fn pattern_places_corner_tiles() {
|
||||
#[test]
|
||||
fn bristle_makes_translucent_strands() {
|
||||
let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
|
||||
assert!(out.len() >= 5, "{}", out.len());
|
||||
assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0));
|
||||
let bb = bounds(&out);
|
||||
@@ -270,7 +278,7 @@ fn tints_colorization_uses_stroke_colour() {
|
||||
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
|
||||
a.colorization = Colorization::Tints;
|
||||
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
|
||||
assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let mut white = rect_art(1.0, 1.0);
|
||||
white.appearance.set_fill(Paint::solid(Color::WHITE));
|
||||
@@ -310,7 +318,7 @@ fn brushes_serde_round_trip() {
|
||||
fn every_default_brush_draws_something() {
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
for b in defaults() {
|
||||
let out = stroke_pieces(b, &bp, &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0));
|
||||
assert!(!out.is_empty(), "{}", b.name);
|
||||
let bb = bounds(&out);
|
||||
assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name);
|
||||
@@ -365,20 +373,20 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
|
||||
fn pressure_maps_to_size_along_the_stroke() {
|
||||
let b = pressure_calli(10.0, 4.0);
|
||||
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
|
||||
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
|
||||
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
|
||||
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
|
||||
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
|
||||
// The stroke weight scales it.
|
||||
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
|
||||
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
|
||||
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
|
||||
// Kept within the valid range: 2 ± 4 never goes below nothing.
|
||||
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
|
||||
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
|
||||
}
|
||||
|
||||
@@ -389,7 +397,7 @@ fn pressure_varies_angle_and_roundness() {
|
||||
let horizontal = line(0.0, 0.0, 100.0, 0.0);
|
||||
let draw = |c: Calligraphic, p: f64| {
|
||||
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
|
||||
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
|
||||
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
|
||||
};
|
||||
use Variation::{Fixed, Pressure};
|
||||
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
|
||||
@@ -399,7 +407,7 @@ fn pressure_varies_angle_and_roundness() {
|
||||
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
|
||||
let wide = |p| {
|
||||
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
|
||||
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
|
||||
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
|
||||
};
|
||||
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
|
||||
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
|
||||
@@ -421,12 +429,18 @@ fn brushes_without_modes_vary_randomly_as_before() {
|
||||
..old.clone()
|
||||
};
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
|
||||
assert_eq!(
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0))
|
||||
);
|
||||
// Fixed ignores the variation; Random still draws a different size per stroke.
|
||||
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
|
||||
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
|
||||
assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
|
||||
// Recorded pressure changes nothing for a brush that doesn't use it.
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
|
||||
assert_eq!(
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
|
||||
);
|
||||
// Modes are saved by name.
|
||||
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
|
||||
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
|
||||
@@ -438,7 +452,7 @@ fn junk_pressure_brushes_draw_finite_art() {
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
|
||||
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
|
||||
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile));
|
||||
let mut pts = vec![];
|
||||
for n in &out {
|
||||
all_points(n, &mut pts);
|
||||
@@ -461,8 +475,9 @@ fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
|
||||
#[test]
|
||||
fn scatter_values_follow_their_modes() {
|
||||
let bp = line(0.0, 0.0, 400.0, 0.0);
|
||||
let heights =
|
||||
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
|
||||
let heights = |b: &Brush, st: &StrokeLayer| -> Vec<f64> {
|
||||
stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect()
|
||||
};
|
||||
// Fixed: every copy at 50 %, whatever the second value.
|
||||
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
|
||||
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
|
||||
@@ -478,7 +493,10 @@ fn scatter_values_follow_their_modes() {
|
||||
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
|
||||
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
|
||||
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
|
||||
assert_eq!(
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0))
|
||||
);
|
||||
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
|
||||
}
|
||||
|
||||
@@ -521,3 +539,37 @@ fn art_colors_list_the_most_used_first() {
|
||||
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
|
||||
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
|
||||
}
|
||||
|
||||
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
|
||||
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
|
||||
#[test]
|
||||
fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
|
||||
use vectorcraft_doc::{PatternDef, Symbol};
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let mut brushed = rect_art(10.0, 4.0);
|
||||
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
|
||||
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
|
||||
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
|
||||
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
|
||||
}
|
||||
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let painted = |pattern: &str| {
|
||||
let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0))
|
||||
};
|
||||
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
|
||||
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
|
||||
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(!out.is_empty());
|
||||
for piece in &out {
|
||||
let children = piece.children().unwrap();
|
||||
let symbols: Vec<&str> = children
|
||||
.iter()
|
||||
.filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None })
|
||||
.collect();
|
||||
assert_eq!(symbols, ["plain"]);
|
||||
let fills: Vec<Paint> =
|
||||
children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect();
|
||||
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
|
||||
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
|
||||
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
|
||||
warnings.extend(damaged);
|
||||
document.limit_depth_noting(&mut warnings);
|
||||
Ok(Imported { document, warnings })
|
||||
}
|
||||
|
||||
|
||||
@@ -506,7 +506,7 @@ impl<'a> Scene<'a> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
|
||||
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
|
||||
Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st))
|
||||
}
|
||||
|
||||
/// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser
|
||||
|
||||
@@ -13,7 +13,7 @@ pub mod generic;
|
||||
pub mod icc;
|
||||
pub mod lab;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -165,6 +165,8 @@ fn generic() -> Arc<GenericCmyk> {
|
||||
}
|
||||
|
||||
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
|
||||
/// Counts the profiles [`register_icc`] has registered ([`profiles_generation`]).
|
||||
static PROFILES_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
|
||||
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
|
||||
@@ -251,9 +253,17 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
u.push(Arc::new(p));
|
||||
drop(u);
|
||||
clear_proof_cache();
|
||||
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
|
||||
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
|
||||
/// as long as the number stays the same.
|
||||
pub fn profiles_generation() -> u64 {
|
||||
PROFILES_GENERATION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`]
|
||||
/// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the
|
||||
/// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their
|
||||
|
||||
@@ -24,8 +24,10 @@ pub struct Clip {
|
||||
}
|
||||
|
||||
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
|
||||
/// for each): deeper ones are left out.
|
||||
pub const MAX_NEST: usize = 128;
|
||||
/// for each): deeper ones are left out. As deep as a document's objects may nest
|
||||
/// ([`crate::MAX_DEPTH`]) under a layer, around the art; clips in groups that deep together are
|
||||
/// merged by [`Document::limit_depth`].
|
||||
pub const MAX_NEST: usize = crate::MAX_DEPTH - 2;
|
||||
|
||||
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
|
||||
pub fn deep_clips() -> String {
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
//! How deep objects may nest.
|
||||
//!
|
||||
//! The tree is walked by recursion everywhere (drawing, bounds, hit tests, saving, exporting), so
|
||||
//! a tree of unbounded depth overflows the stack: an abort no guard catches. No document deeper
|
||||
//! than [`MAX_DEPTH`] is ever kept, nor one whose symbols and patterns draw deeper than
|
||||
//! [`MAX_DRAWN_DEPTH`] (a symbol holding an instance of itself, or a pattern painting its own
|
||||
//! tile, draws without end): importers and the native reader merge deeper objects into the group
|
||||
//! around them and leave such instances and paints out
|
||||
//! ([`Document::limit_depth`], with [`merged_note`] and [`symbols_note`] as their warnings), and
|
||||
//! the engine refuses an edit that would nest deeper ([`Document::too_deep`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
|
||||
use crate::{Document, Node, NodeKind};
|
||||
|
||||
/// The deepest an object may be: a top-level layer is 1 deep, what it holds 2, and so on (the art
|
||||
/// of an opacity mask is one deeper than the object it masks). A symbol's or pattern's art counts
|
||||
/// from 1 again.
|
||||
///
|
||||
/// A native file writes three levels of JSON for each level of objects, and its reader stops at
|
||||
/// 128 levels: 32 leaves the deepest object 29 of them for its own data, so every document that
|
||||
/// can be made can be saved and opened again.
|
||||
pub const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// The deepest an object may be drawn: as [`MAX_DEPTH`], the art of a symbol counted under its
|
||||
/// instance and a pattern's tile under the object it paints (and what those use, under them).
|
||||
/// Symbols a few objects deep nest in one another dozens deep within it.
|
||||
pub const MAX_DRAWN_DEPTH: usize = 4 * MAX_DEPTH;
|
||||
|
||||
/// The warning of a file whose objects were nested deeper than [`MAX_DEPTH`].
|
||||
pub fn merged_note() -> String {
|
||||
format!("objects nested more than {MAX_DEPTH} deep were merged into the group around them")
|
||||
}
|
||||
|
||||
/// The warning of a file with symbols or patterns that use themselves or are drawn deeper than
|
||||
/// [`MAX_DRAWN_DEPTH`].
|
||||
pub fn symbols_note() -> String {
|
||||
format!(
|
||||
"symbol instances and pattern paints inside their own symbol or pattern, or drawn more than {MAX_DRAWN_DEPTH} objects deep, were left out"
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `f` where recursion has room: on a thread of its own with a 16 MB stack (the main thread
|
||||
/// has 1 MB on Windows), or here where there are no threads (the web) or none can be started. For
|
||||
/// readers whose recursion is capped but heavy; a panic in `f` goes on to the caller.
|
||||
pub fn with_deep_stack<T: Send>(f: impl Fn() -> T + Sync) -> T {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let threaded = std::thread::scope(|s| {
|
||||
let thread = std::thread::Builder::new().stack_size(16 << 20).spawn_scoped(s, &f).ok()?;
|
||||
Some(thread.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)))
|
||||
});
|
||||
if let Some(done) = threaded {
|
||||
return done;
|
||||
}
|
||||
}
|
||||
f()
|
||||
}
|
||||
|
||||
/// Is anything in `now` (each `depth` deep) deeper than [`MAX_DEPTH`], or `over` (given its
|
||||
/// depth)? Without recursion: the trees may be of any depth. With `before` (the same list before
|
||||
/// an edit, within the caps), only what the edit changed is looked at: a subtree still shared with
|
||||
/// it, in the same place, is as it was.
|
||||
fn deeper(now: &[Arc<Node>], before: Option<&[Arc<Node>]>, depth: usize, over: &dyn Fn(&Node, usize) -> bool) -> bool {
|
||||
type Pending<'a> = Vec<(&'a Node, Option<&'a Node>, usize)>;
|
||||
fn push<'a>(stack: &mut Pending<'a>, now: &'a [Arc<Node>], before: Option<&'a [Arc<Node>]>, depth: usize) {
|
||||
for (i, n) in now.iter().enumerate() {
|
||||
let was = before.and_then(|b| b.get(i));
|
||||
if !was.is_some_and(|w| Arc::ptr_eq(w, n)) {
|
||||
stack.push((n, was.map(|w| &**w), depth));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stack = vec![];
|
||||
push(&mut stack, now, before, depth);
|
||||
while let Some((n, was, d)) = stack.pop() {
|
||||
if d > MAX_DEPTH || over(n, d) {
|
||||
return true;
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
push(&mut stack, ch, was.and_then(Node::children).map(Vec::as_slice), d + 1);
|
||||
}
|
||||
if let Some(m) = &n.extras().mask {
|
||||
let was = was.and_then(|w| w.extras().mask.as_ref()).map(|m| std::slice::from_ref(&m.art));
|
||||
push(&mut stack, std::slice::from_ref(&m.art), was, d + 1);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// [`deeper`] for the nesting alone.
|
||||
fn nested_deeper(now: &[Arc<Node>], depth: usize) -> bool {
|
||||
deeper(now, None, depth, &|_, _| false)
|
||||
}
|
||||
|
||||
/// Merge what is deeper than [`MAX_DEPTH`] in `nodes` (each `depth` deep) into the group around
|
||||
/// it: a container at the cap is replaced by what it holds (a clipping group's clipping path and
|
||||
/// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns
|
||||
/// whether anything changed. Recurses no deeper than the cap.
|
||||
fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
|
||||
let mut changed = false;
|
||||
let mut i = 0;
|
||||
while let Some(n) = nodes.get_mut(i) {
|
||||
if !nested_deeper(std::slice::from_ref(n), depth) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let n = Arc::make_mut(n);
|
||||
if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) {
|
||||
n.extras_mut().mask = None;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
if let Some(ch) = n.children_mut() {
|
||||
limit(ch, depth + 1);
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
|
||||
let (visible, locked) = (n.visible, n.locked);
|
||||
let mut held = n.children_mut().map(std::mem::take).unwrap_or_default();
|
||||
if clip && !held.is_empty() {
|
||||
held.remove(0);
|
||||
}
|
||||
for c in &mut held {
|
||||
if (!visible && c.visible) || (locked && !c.locked) {
|
||||
let c = Arc::make_mut(c);
|
||||
c.visible &= visible;
|
||||
c.locked |= locked;
|
||||
}
|
||||
}
|
||||
// What it held takes its place, and is looked at in its turn.
|
||||
nodes.splice(i..=i, held);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an
|
||||
/// instance its symbol's art and a pattern paint its tile's.
|
||||
type Def<'a> = (bool, &'a str);
|
||||
|
||||
/// How deep each symbol's and pattern's art is drawn, the art of the symbols and patterns it uses
|
||||
/// included (drawn under the object that uses them): `None` for one that uses itself, in its art
|
||||
/// or through what that uses, or is drawn deeper than [`MAX_DRAWN_DEPTH`].
|
||||
type Heights<'a> = HashMap<Def<'a>, Option<usize>>;
|
||||
|
||||
/// What `n` itself draws by name: the symbol it is an instance of, the patterns that paint it.
|
||||
fn uses(n: &Node) -> impl Iterator<Item = Def<'_>> {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
|
||||
_ => None,
|
||||
};
|
||||
let patterns = crate::swatches::node_paints(n).filter_map(|p| match p {
|
||||
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
|
||||
_ => None,
|
||||
});
|
||||
symbol.into_iter().chain(patterns)
|
||||
}
|
||||
|
||||
/// [`Heights`] of the document's symbols and patterns. Without recursion: they may use one
|
||||
/// another in a chain of any length. Their own art is within [`MAX_DEPTH`].
|
||||
fn heights(doc: &Document) -> Heights<'_> {
|
||||
// Each one's own depth, and what its art uses with the deepest use of each.
|
||||
type Own<'a> = (usize, Vec<(Def<'a>, usize)>);
|
||||
let arts = (doc.symbols.iter().map(|s| ((false, s.name.as_str()), std::slice::from_ref(&s.art))))
|
||||
.chain(doc.patterns.iter().map(|p| ((true, p.name.as_str()), &p.art[..])));
|
||||
let mut own: HashMap<Def, Own> = HashMap::new();
|
||||
for (def, art) in arts {
|
||||
let (mut deepest, mut used) = (0, HashMap::<Def, usize>::new());
|
||||
let mut stack: Vec<(&Node, usize)> = art.iter().map(|n| (&**n, 1)).collect();
|
||||
while let Some((n, d)) = stack.pop() {
|
||||
deepest = deepest.max(d);
|
||||
for u in uses(n) {
|
||||
let at = used.entry(u).or_default();
|
||||
*at = (*at).max(d);
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1)));
|
||||
}
|
||||
// (The first of two of one name is the one drawn.)
|
||||
own.entry(def).or_insert((deepest, used.into_iter().collect()));
|
||||
}
|
||||
// Depth first over what uses what. One met again while it is being worked out (`None` in its
|
||||
// entry) uses itself.
|
||||
let mut found: HashMap<Def, Option<Option<usize>>> = HashMap::new();
|
||||
let starts: Vec<Def> = own.keys().copied().collect();
|
||||
for start in starts {
|
||||
if found.contains_key(&start) {
|
||||
continue;
|
||||
}
|
||||
// (which, the next of its uses to look at, the deepest found so far: `None` once it can't be drawn)
|
||||
let mut path: Vec<(Def, usize, Option<usize>)> = vec![(start, 0, own.get(&start).map(|o| o.0))];
|
||||
found.insert(start, None);
|
||||
while let Some(&(def, next, deepest)) = path.last() {
|
||||
let used = own.get(&def).map_or(&[][..], |o| &o.1);
|
||||
let Some(&(other, at)) = used.get(next) else {
|
||||
// Everything it uses is worked out.
|
||||
let deepest = deepest.filter(|d| *d <= MAX_DRAWN_DEPTH);
|
||||
found.insert(def, Some(deepest));
|
||||
path.pop();
|
||||
if let Some((user, next, held)) = path.last_mut() {
|
||||
let at = own.get(user).and_then(|o| o.1.get(next.wrapping_sub(1))).map_or(1, |u| u.1);
|
||||
*held = held.zip(deepest).map(|(h, d)| h.max(at + d));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let known = found.get(&other).copied();
|
||||
if known.is_none() && own.contains_key(&other) {
|
||||
found.insert(other, None);
|
||||
}
|
||||
let Some((_, next, deepest)) = path.last_mut() else { break };
|
||||
*next += 1;
|
||||
match known {
|
||||
// Not in the document: it draws nothing.
|
||||
None if !own.contains_key(&other) => {}
|
||||
None => path.push((other, 0, own.get(&other).map(|o| o.0))),
|
||||
Some(Some(Some(d))) => *deepest = deepest.map(|h| h.max(at + d)),
|
||||
// It uses itself (it is on the way down here), or can't be drawn.
|
||||
Some(None | Some(None)) => *deepest = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
found.into_iter().map(|(def, h)| (def, h.flatten())).collect()
|
||||
}
|
||||
|
||||
/// Can't `used`, by an object `depth` deep, be drawn there?
|
||||
fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool {
|
||||
heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH))
|
||||
}
|
||||
|
||||
/// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object):
|
||||
/// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees
|
||||
/// within [`MAX_DEPTH`].
|
||||
fn strip(nodes: &mut Vec<Arc<Node>>, depth: usize, bad: &dyn Fn(Def, usize) -> bool) -> bool {
|
||||
let any = |n: &Node, d: usize| uses(n).any(|u| bad(u, d));
|
||||
let before = nodes.len();
|
||||
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if bad((false, symbol.as_str()), depth)));
|
||||
let mut changed = nodes.len() != before;
|
||||
for n in nodes.iter_mut() {
|
||||
if !deeper(std::slice::from_ref(n), None, depth, &any) {
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let n = Arc::make_mut(n);
|
||||
if uses(n).any(|u| bad(u, depth)) {
|
||||
for p in crate::swatches::node_paints_mut(n) {
|
||||
if matches!(p, Paint::Pattern { pattern, .. } if bad((true, pattern.as_str()), depth)) {
|
||||
*p = Paint::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// An opacity mask whose art is such an instance goes with it.
|
||||
let mut mask = n.extras().mask.as_ref().map(|m| vec![m.art.clone()]);
|
||||
if let Some(art) = &mut mask
|
||||
&& strip(art, depth + 1, bad)
|
||||
{
|
||||
match art.pop() {
|
||||
Some(art) => {
|
||||
if let Some(m) = n.extras_mut().mask.as_mut() {
|
||||
m.art = art;
|
||||
}
|
||||
}
|
||||
None => n.extras_mut().mask = None,
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
strip(ch, depth + 1, bad);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Is anything nested deeper than [`MAX_DEPTH`], or drawn deeper than [`MAX_DRAWN_DEPTH`]
|
||||
/// (a symbol or pattern that uses itself is)? With `before` (this document before an edit,
|
||||
/// itself within the caps), only what the edit changed is looked at.
|
||||
pub fn too_deep(&self, before: Option<&Document>) -> bool {
|
||||
fn symbol(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
|
||||
d.symbols.get(i).map(|s| std::slice::from_ref(&s.art))
|
||||
}
|
||||
fn pattern(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
|
||||
d.patterns.get(i).map(|p| &p.art[..])
|
||||
}
|
||||
let nested = |_: &Node, _: usize| false;
|
||||
if (0..self.symbols.len()).any(|i| symbol(self, i).is_some_and(|art| deeper(art, before.and_then(|b| symbol(b, i)), 1, &nested)))
|
||||
|| (0..self.patterns.len()).any(|i| pattern(self, i).is_some_and(|art| deeper(art, before.and_then(|b| pattern(b, i)), 1, &nested)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if self.symbols.is_empty() && self.patterns.is_empty() {
|
||||
return deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &nested);
|
||||
}
|
||||
let heights = heights(self);
|
||||
if heights.values().any(Option::is_none) {
|
||||
return true;
|
||||
}
|
||||
// Symbols and patterns draw as deep as before when none changed; else every use of them
|
||||
// is looked at again.
|
||||
let same = |x: &[Arc<Node>], y: &[Arc<Node>]| x.len() == y.len() && x.iter().zip(y).all(|(x, y)| Arc::ptr_eq(x, y));
|
||||
let before = before.filter(|b| {
|
||||
b.symbols.len() == self.symbols.len()
|
||||
&& b.patterns.len() == self.patterns.len()
|
||||
&& b.symbols.iter().zip(&self.symbols).all(|(x, y)| x.name == y.name && Arc::ptr_eq(&x.art, &y.art))
|
||||
&& b.patterns.iter().zip(&self.patterns).all(|(x, y)| x.name == y.name && same(&x.art, &y.art))
|
||||
});
|
||||
let over = |n: &Node, d: usize| uses(n).any(|u| undrawable(&heights, u, d));
|
||||
deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &over)
|
||||
}
|
||||
|
||||
/// [`Self::limit_depth`] → whether objects were merged, and whether uses of symbols and
|
||||
/// patterns were left out.
|
||||
fn limit_depths(&mut self) -> (bool, bool) {
|
||||
let mut merged = limit(&mut self.layers, 1);
|
||||
for s in &mut self.symbols {
|
||||
// The art itself is 1 deep, so it stays the one object it is.
|
||||
let mut art = vec![s.art.clone()];
|
||||
if limit(&mut art, 1)
|
||||
&& let Some(one) = art.pop()
|
||||
{
|
||||
s.art = one;
|
||||
merged = true;
|
||||
}
|
||||
}
|
||||
for p in &mut self.patterns {
|
||||
merged |= limit(&mut p.art, 1);
|
||||
}
|
||||
if self.symbols.is_empty() && self.patterns.is_empty() {
|
||||
return (merged, false);
|
||||
}
|
||||
// The symbols and patterns that can't be drawn lose what their art uses: each is then as
|
||||
// deep as its own art, and what uses it can be drawn.
|
||||
let (symbols, patterns): (Vec<usize>, Vec<usize>) = {
|
||||
let heights = heights(self);
|
||||
let bad = |def: Def| heights.get(&def).is_some_and(Option::is_none);
|
||||
(
|
||||
(0..self.symbols.len()).filter(|i| self.symbols.get(*i).is_some_and(|s| bad((false, s.name.as_str())))).collect(),
|
||||
(0..self.patterns.len()).filter(|i| self.patterns.get(*i).is_some_and(|p| bad((true, p.name.as_str())))).collect(),
|
||||
)
|
||||
};
|
||||
let mut left_out = !(symbols.is_empty() && patterns.is_empty());
|
||||
let all = |_: Def, _: usize| true;
|
||||
for i in symbols {
|
||||
let Some(s) = self.symbols.get_mut(i) else { continue };
|
||||
let mut art = vec![s.art.clone()];
|
||||
strip(&mut art, 1, &all);
|
||||
// (An instance alone was the whole art: an empty group stands in.)
|
||||
s.art = art.pop().unwrap_or_else(|| Arc::new(Node::group(s.art.id, vec![])));
|
||||
}
|
||||
for i in patterns {
|
||||
if let Some(p) = self.patterns.get_mut(i) {
|
||||
strip(&mut p.art, 1, &all);
|
||||
}
|
||||
}
|
||||
// Then the uses drawn too deep where they are.
|
||||
let mut layers = self.layers.clone();
|
||||
left_out |= {
|
||||
let heights = heights(self);
|
||||
strip(&mut layers, 1, &|used, depth| undrawable(&heights, used, depth))
|
||||
};
|
||||
self.layers = layers;
|
||||
(merged, left_out)
|
||||
}
|
||||
|
||||
/// Bring the document within the caps: merge the objects nested deeper than [`MAX_DEPTH`]
|
||||
/// into the group around them, and leave out the uses of symbols and patterns that can't be
|
||||
/// drawn (inside their own symbol or pattern, or deeper than [`MAX_DRAWN_DEPTH`]). What an
|
||||
/// importer does to what it read. Returns whether anything changed.
|
||||
pub fn limit_depth(&mut self) -> bool {
|
||||
let (merged, left_out) = self.limit_depths();
|
||||
merged || left_out
|
||||
}
|
||||
|
||||
/// [`Self::limit_depth`], adding [`merged_note`] and [`symbols_note`] to an importer's
|
||||
/// `warnings` for what it did.
|
||||
pub fn limit_depth_noting(&mut self, warnings: &mut Vec<String>) {
|
||||
let (merged, left_out) = self.limit_depths();
|
||||
warnings.extend(merged.then(merged_note));
|
||||
warnings.extend(left_out.then(symbols_note));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{NodeId, OpacityMask};
|
||||
|
||||
/// One path in `levels` groups, each holding a second path beside the next group.
|
||||
fn nested(levels: usize) -> Arc<Node> {
|
||||
let mut id = 0;
|
||||
let mut next = || {
|
||||
id += 1;
|
||||
NodeId(id)
|
||||
};
|
||||
let path = |id| Arc::new(Node::path(id, Default::default(), Default::default()));
|
||||
let mut inner = path(next());
|
||||
for _ in 0..levels {
|
||||
inner = Arc::new(Node::group(next(), vec![inner, path(next())]));
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
fn doc(art: Arc<Node>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
ch.push(art);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_at_the_cap_is_left_alone() {
|
||||
// The layer, the groups and the path in them: MAX_DEPTH deep.
|
||||
let mut d = doc(nested(MAX_DEPTH - 2));
|
||||
let before = d.clone();
|
||||
assert!(!d.too_deep(None));
|
||||
assert!(!d.limit_depth());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeper_objects_are_merged_into_the_group_around_them() {
|
||||
for levels in [MAX_DEPTH - 1, MAX_DEPTH + 5, 100_000] {
|
||||
let mut d = doc(nested(levels));
|
||||
assert!(d.too_deep(None), "{levels}");
|
||||
assert!(d.limit_depth(), "{levels}");
|
||||
assert!(!d.too_deep(None), "{levels}");
|
||||
// Every path is kept, under the layer and the groups down to the cap.
|
||||
assert_eq!(d.node_count(), 1 + (MAX_DEPTH - 2) + (levels + 1), "{levels}");
|
||||
assert!(!d.limit_depth(), "nothing more to merge");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() {
|
||||
let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true });
|
||||
inner.visible = false;
|
||||
let mut art = Arc::new(inner);
|
||||
for i in 0..MAX_DEPTH {
|
||||
art = Arc::new(Node::group(NodeId(9001 + i as u64), vec![art]));
|
||||
}
|
||||
let mut masked = Node::group(NodeId(8000), vec![]);
|
||||
masked.extras_mut().mask = Some(Box::new(OpacityMask::new((*nested(MAX_DEPTH)).clone(), true)));
|
||||
let mut d = doc(art);
|
||||
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
ch.push(Arc::new(masked));
|
||||
}
|
||||
assert!(d.limit_depth());
|
||||
assert!(!d.too_deep(None));
|
||||
let mut leaves = vec![];
|
||||
d.walk(|n| {
|
||||
if n.children().is_none() {
|
||||
leaves.push(n.visible);
|
||||
}
|
||||
});
|
||||
assert_eq!(leaves, [false], "the clipping path is gone, what it clipped is still hidden");
|
||||
assert!(d.node(NodeId(8000)).is_some_and(|n| n.extras().mask.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_is_checked_by_what_it_changed() {
|
||||
let before = doc(nested(MAX_DEPTH - 2));
|
||||
let mut wrapped = before.clone();
|
||||
if let Some(ch) = wrapped.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
let all = std::mem::take(ch);
|
||||
ch.push(Arc::new(Node::group(NodeId(7000), all)));
|
||||
}
|
||||
assert!(wrapped.too_deep(Some(&before)), "the same art, one deeper");
|
||||
let mut beside = before.clone();
|
||||
if let Some(ch) = beside.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
ch.push(nested(3));
|
||||
}
|
||||
assert!(!beside.too_deep(Some(&before)));
|
||||
assert!(!before.too_deep(Some(&before)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_and_patterns_are_limited_too() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
|
||||
assert!(d.too_deep(None));
|
||||
assert!(d.limit_depth());
|
||||
assert!(!d.too_deep(None));
|
||||
assert!(d.symbols.first().is_some_and(|s| s.art.children().is_some()), "still one object");
|
||||
}
|
||||
|
||||
fn instance(id: u64, of: &str) -> Arc<Node> {
|
||||
Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: of.into(), xf: Default::default() }))
|
||||
}
|
||||
|
||||
/// `count` symbols, each `levels` groups around an instance of the next; the last one's of `last`.
|
||||
fn symbols(count: usize, levels: usize, last: &str) -> Document {
|
||||
let mut d = doc(instance(1, "s0"));
|
||||
for i in 0..count {
|
||||
let mut art = instance(2, &if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
|
||||
for _ in 0..levels {
|
||||
art = Arc::new(Node::group(NodeId(3), vec![art]));
|
||||
}
|
||||
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn instances(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
let mut count = |x: &Node| n += usize::from(matches!(x.kind, NodeKind::SymbolInstance { .. }));
|
||||
d.walk(&mut count);
|
||||
d.symbols.iter().for_each(|s| s.art.walk(&mut count));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_in_symbols_are_drawn_only_so_deep() {
|
||||
// Three objects deep each (two groups and the instance), under the instance on the layer.
|
||||
let fits = MAX_DRAWN_DEPTH / 3 - 1;
|
||||
let mut d = symbols(fits, 2, "none");
|
||||
assert!(!d.too_deep(None) && !d.limit_depth());
|
||||
let mut d = symbols(fits + 2, 2, "none");
|
||||
assert!(d.too_deep(None));
|
||||
let before = instances(&d);
|
||||
assert!(d.limit_depth() && !d.too_deep(None));
|
||||
assert!(instances(&d) < before && instances(&d) > 0, "only the instances drawn too deep go");
|
||||
// A chain of any length is measured, not walked by recursion.
|
||||
let mut d = symbols(100_000, 0, "none");
|
||||
assert!(d.too_deep(None) && d.limit_depth() && !d.too_deep(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_holding_itself_loses_the_instance() {
|
||||
for (count, last) in [(1, "s0"), (3, "s0"), (3, "s1")] {
|
||||
let mut d = symbols(count, 1, last);
|
||||
assert!(d.too_deep(None), "{count} {last}");
|
||||
let mut warnings = vec![];
|
||||
d.limit_depth_noting(&mut warnings);
|
||||
assert_eq!(warnings, [symbols_note()]);
|
||||
assert!(!d.too_deep(None), "{count} {last}");
|
||||
assert!(d.symbols.iter().all(|s| s.art.children().is_some()), "the art is still a group");
|
||||
}
|
||||
// An edit that makes one hold itself is seen, whatever else stayed as it was.
|
||||
let before = symbols(2, 1, "none");
|
||||
let mut after = before.clone();
|
||||
after.symbols[1].art = Arc::new(Node::group(NodeId(9), vec![instance(8, "s0")]));
|
||||
assert!(!before.too_deep(None) && after.too_deep(Some(&before)));
|
||||
}
|
||||
|
||||
/// A path painted with the pattern named `with`.
|
||||
fn painted(id: u64, with: &str) -> Arc<Node> {
|
||||
let fill = Paint::Pattern { pattern: with.into(), xf: Default::default() };
|
||||
Arc::new(Node::path(NodeId(id), Default::default(), crate::Appearance::basic(fill, Paint::None, 0.0)))
|
||||
}
|
||||
|
||||
fn pattern_fills(d: &Document) -> usize {
|
||||
let mut n = 0;
|
||||
d.visit_paints(&mut |p| n += usize::from(matches!(p, Paint::Pattern { .. })));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_painting_its_own_tile_loses_the_paint() {
|
||||
// One that paints its own tile; two that paint each other's; one in a symbol it holds.
|
||||
let itself = |d: &mut Document| d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "p")]));
|
||||
let each_other = |d: &mut Document| {
|
||||
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
|
||||
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "p")]));
|
||||
};
|
||||
let through_a_symbol = |d: &mut Document| {
|
||||
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
|
||||
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
|
||||
};
|
||||
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
|
||||
let mut d = doc(painted(1, "p"));
|
||||
make(&mut d);
|
||||
assert!(d.too_deep(None));
|
||||
let mut warnings = vec![];
|
||||
d.limit_depth_noting(&mut warnings);
|
||||
assert_eq!(warnings, [symbols_note()]);
|
||||
assert!(!d.too_deep(None));
|
||||
assert_eq!(pattern_fills(&d), 1, "the object on the layer keeps its pattern");
|
||||
}
|
||||
// A pattern whose tile is painted with another is fine.
|
||||
let mut d = doc(painted(1, "p"));
|
||||
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
|
||||
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")]));
|
||||
assert!(!d.too_deep(None) && !d.limit_depth());
|
||||
}
|
||||
}
|
||||
+79
-9
@@ -11,6 +11,7 @@ pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod corners;
|
||||
pub mod depth;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
mod id_index;
|
||||
@@ -30,6 +31,7 @@ pub mod overprint;
|
||||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub use pixels::MAX_RASTER_ALLOC;
|
||||
pub mod placed_document;
|
||||
pub mod profiles;
|
||||
pub mod puppet;
|
||||
@@ -79,6 +81,7 @@ pub use appearance::{
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::MAX_DEPTH;
|
||||
pub use graph::{
|
||||
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
|
||||
ValueAxisSide,
|
||||
@@ -773,7 +776,9 @@ impl Document {
|
||||
/// Allocate a fresh node id.
|
||||
pub fn alloc_id(&mut self) -> NodeId {
|
||||
let id = NodeId(self.next_id);
|
||||
self.next_id += 1;
|
||||
// Never reached from a counter [`Self::fix_next_id`] has seen (it stays under
|
||||
// [`MAX_NEXT_ID`]); saturating so that no counter can overflow.
|
||||
self.next_id = self.next_id.saturating_add(1);
|
||||
id
|
||||
}
|
||||
pub fn peek_next_id(&self) -> u64 {
|
||||
@@ -781,14 +786,22 @@ impl Document {
|
||||
}
|
||||
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
|
||||
pub fn fix_next_id(&mut self) {
|
||||
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
|
||||
// counter up there: new ids start under them and never reach them, where counting on from
|
||||
// the largest would run out and hand out ids the document already uses.
|
||||
let mut max = 0;
|
||||
let mut see = |id: u64| {
|
||||
if id < MAX_NEXT_ID {
|
||||
max = max.max(id);
|
||||
}
|
||||
};
|
||||
for l in &self.layers {
|
||||
l.walk(&mut |n| max = max.max(n.id.0));
|
||||
l.walk(&mut |n| see(n.id.0));
|
||||
}
|
||||
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
|
||||
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
|
||||
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
|
||||
self.next_id = self.next_id.max(max.saturating_add(1));
|
||||
self.slices.iter().for_each(|s| see(s.id.0));
|
||||
self.assets.iter().for_each(|a| see(a.id));
|
||||
let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0);
|
||||
self.next_id = counter.max(max.saturating_add(1));
|
||||
}
|
||||
|
||||
/// Find a node anywhere in the tree.
|
||||
@@ -1374,7 +1387,7 @@ impl Document {
|
||||
self.guides.extend(copies);
|
||||
}
|
||||
pub fn next_artboard_id(&self) -> u32 {
|
||||
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
|
||||
free_id(self.artboards.iter().map(|a| a.id))
|
||||
}
|
||||
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
|
||||
self.patterns.iter().find(|p| p.name == name)
|
||||
@@ -1458,7 +1471,7 @@ impl Document {
|
||||
}
|
||||
/// An id no graphic style has.
|
||||
pub fn next_graphic_style_id(&self) -> u32 {
|
||||
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
|
||||
free_id(self.graphic_styles.iter().map(|g| g.id))
|
||||
}
|
||||
/// The id of graphic style `index`, assigning one if it has none yet.
|
||||
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
|
||||
@@ -1497,11 +1510,29 @@ fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so
|
||||
/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718).
|
||||
const MAX_NEXT_ID: u64 = 1 << 62;
|
||||
|
||||
/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there
|
||||
/// is (a damaged file), the smallest one free.
|
||||
fn free_id(used: impl Iterator<Item = u32>) -> u32 {
|
||||
let mut used: Vec<u32> = used.collect();
|
||||
if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) {
|
||||
return next;
|
||||
}
|
||||
used.sort_unstable();
|
||||
used.dedup();
|
||||
// Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them.
|
||||
let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count();
|
||||
u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
|
||||
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
|
||||
pub fn reserve_ids(&mut self, next: u64) {
|
||||
self.next_id = self.next_id.max(next);
|
||||
self.next_id = self.next_id.max(next.min(MAX_NEXT_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1626,6 +1657,45 @@ mod tests {
|
||||
assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1);
|
||||
}
|
||||
|
||||
/// #1718: a file whose ids or counters are at their type's largest value still hands out ids
|
||||
/// nothing in it uses (and never 0), instead of overflowing.
|
||||
#[test]
|
||||
fn exhausted_ids_in_a_file_dont_run_out() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v["next_id"] = serde_json::json!(u64::MAX);
|
||||
// Object `a` gets the largest id there is.
|
||||
fn renumber(v: &mut serde_json::Value, from: u64) {
|
||||
match v {
|
||||
serde_json::Value::Object(o) => {
|
||||
if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) {
|
||||
o.insert("id".into(), serde_json::json!(u64::MAX));
|
||||
}
|
||||
o.values_mut().for_each(|c| renumber(c, from));
|
||||
}
|
||||
serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
renumber(&mut v["layers"], a.0);
|
||||
let mut d: Document = serde_json::from_value(v).unwrap();
|
||||
assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept");
|
||||
d.fix_next_id();
|
||||
let fresh: Vec<NodeId> = (0..3).map(|_| d.alloc_id()).collect();
|
||||
assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}");
|
||||
assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]);
|
||||
// A counter left at the end by other means stays there rather than wrapping to 0.
|
||||
d.next_id = u64::MAX;
|
||||
assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX));
|
||||
d.reserve_ids(u64::MAX);
|
||||
// Artboards and graphic styles: one past the largest, else the smallest id free.
|
||||
assert_eq!(free_id([1, 2, 7].into_iter()), 8);
|
||||
assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3);
|
||||
assert_eq!(free_id([u32::MAX].into_iter()), 1);
|
||||
d.artboards[0].id = u32::MAX;
|
||||
assert_eq!(d.next_artboard_id(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let (d, _, _) = doc_with_rects();
|
||||
|
||||
@@ -6,6 +6,22 @@ use std::sync::Arc;
|
||||
|
||||
use crate::ImageBlob;
|
||||
|
||||
/// The most memory the decoded RGBA pixels of one image may take: the allowance the importers
|
||||
/// and the renderer decode within (a 60 in sheet at 300 ppi is ~475 MB). The web app has 4 GB to
|
||||
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
|
||||
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
|
||||
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
|
||||
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
|
||||
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if u64::from(w) * u64::from(h) * 4 > max {
|
||||
return None;
|
||||
}
|
||||
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// A PNG blob of `png` bytes.
|
||||
pub fn png(png: Vec<u8>) -> Self {
|
||||
@@ -21,7 +37,7 @@ impl ImageBlob {
|
||||
/// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the
|
||||
/// image can't be decoded or no pixel changed.
|
||||
pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option<ImageBlob> {
|
||||
let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8();
|
||||
let mut img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
|
||||
let mut changed = false;
|
||||
for px in img.pixels_mut().filter(|p| p.0[3] > 0) {
|
||||
@@ -67,7 +83,7 @@ impl ImageBlob {
|
||||
LAST.with_borrow_mut(|last| match last {
|
||||
Some((k, img)) if *k == key => Some(img.clone()),
|
||||
_ => {
|
||||
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
|
||||
let img = Arc::new(rgba_within(&self.bytes, MAX_RASTER_ALLOC)?);
|
||||
*last = Some((key, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
@@ -115,6 +131,18 @@ mod tests {
|
||||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
|
||||
/// the decoder's own limit lets through is four times that as RGBA.
|
||||
#[test]
|
||||
fn pixels_past_the_allowance_are_not_decoded() {
|
||||
let grey = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
|
||||
let mut png = Vec::new();
|
||||
grey.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert!(rgba_within(&png, 31).is_none(), "8 grey bytes are 32 as RGBA");
|
||||
assert_eq!(rgba_within(&png, 32).map(|i| i.dimensions()), Some((4, 2)));
|
||||
assert!(rgba_within(b"not an image", u64::MAX).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_a_pixel_or_the_average_around_it() {
|
||||
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
|
||||
|
||||
@@ -19,6 +19,11 @@ impl<T> Shared<T> {
|
||||
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
|
||||
Arc::ptr_eq(&a.0, &b.0)
|
||||
}
|
||||
/// A weak reference to the value: it keeps the allocation, so the value's address names it
|
||||
/// while the reference is kept, but not the value.
|
||||
pub fn downgrade(this: &Self) -> std::sync::Weak<T> {
|
||||
Arc::downgrade(&this.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Shared<T> {
|
||||
|
||||
@@ -109,10 +109,11 @@ impl Document {
|
||||
}
|
||||
|
||||
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
|
||||
/// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the
|
||||
/// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may
|
||||
/// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed
|
||||
/// paint (and the paths to them) are copied. Returns the number of changed paints.
|
||||
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
|
||||
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
|
||||
/// `f(colour, link, tint)` may change them and returns true when it did
|
||||
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
|
||||
/// copied. Returns the number of changed paints.
|
||||
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
|
||||
let mut n = map_trees(&mut self.layers, f);
|
||||
for s in &mut self.symbols {
|
||||
@@ -152,11 +153,12 @@ impl Document {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles
|
||||
/// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint.
|
||||
/// Visit every paint held by the art (opacity masks included), symbol definitions, pattern
|
||||
/// tiles and graphic styles (fills, strokes and text runs), and the colour of every
|
||||
/// gradient-mesh point as a solid paint.
|
||||
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
|
||||
let mut tree = |n: &Node| {
|
||||
n.walk(&mut |m| {
|
||||
walk_with_masks(n, &mut |m| {
|
||||
node_paints(m).for_each(&mut *f);
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
|
||||
@@ -224,7 +226,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
|
||||
}
|
||||
|
||||
/// The paints a node holds itself (not its children): appearance items, then text runs.
|
||||
fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
let runs = match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.as_slice(),
|
||||
_ => &[],
|
||||
@@ -233,7 +235,7 @@ fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
let runs = match &mut n.kind {
|
||||
NodeKind::Text(t) => t.runs.as_mut_slice(),
|
||||
_ => &mut [],
|
||||
@@ -280,9 +282,31 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Opacity mask art lives outside the tree, but its colours are the object's too (#1735).
|
||||
if let Some(mask) = n.extras().mask.as_deref()
|
||||
&& let Some((new, k)) = map_node(&mask.art, f)
|
||||
{
|
||||
let m = out.get_or_insert_with(|| n.clone());
|
||||
if let Some(slot) = m.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
|
||||
slot.art = Arc::new(new);
|
||||
count += k;
|
||||
}
|
||||
}
|
||||
out.map(|m| (m, count))
|
||||
}
|
||||
|
||||
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
|
||||
/// object, before its children).
|
||||
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if let Some(mask) = n.extras().mask.as_deref() {
|
||||
walk_with_masks(&mask.art, f);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk_with_masks(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
|
||||
@@ -108,6 +108,10 @@ const MAX_SCALE: f64 = 100_000.0;
|
||||
const MAX_MOVE: f64 = 1.0e7;
|
||||
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
|
||||
const MAX_REACH: f64 = 1.0e9;
|
||||
/// Most anchors a Transform effect's copies make of a path, the original included: 1000 copies of
|
||||
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
|
||||
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
|
||||
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
|
||||
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
|
||||
@@ -115,6 +119,11 @@ const MAX_REACH: f64 = 1.0e9;
|
||||
/// 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.
|
||||
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
transform_within(path, b, p, seed, MAX_COPY_ANCHORS)
|
||||
}
|
||||
|
||||
/// [`transform`] whose copies make at most `max` anchors of the path, the original included.
|
||||
pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData {
|
||||
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 };
|
||||
@@ -132,9 +141,17 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
|
||||
return path.transformed(m);
|
||||
}
|
||||
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
|
||||
// Each copy adds the path once more, and a Transform after this one copies the lot again:
|
||||
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
|
||||
let each = path.anchor_count();
|
||||
let mut anchors = each;
|
||||
let mut out = path.clone();
|
||||
let mut cur = path.clone();
|
||||
for _ in 0..copies {
|
||||
anchors = anchors.saturating_add(each);
|
||||
if anchors > max {
|
||||
break;
|
||||
}
|
||||
cur = cur.transformed(m);
|
||||
if !cur.bounds().is_some_and(within) {
|
||||
break;
|
||||
|
||||
@@ -113,19 +113,19 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
|
||||
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
|
||||
_ => vectorcraft_three_d::plane(path, bounds, view),
|
||||
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
|
||||
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
|
||||
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, _) =
|
||||
let (path, rule) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
if let Some(v) = p.get("bevel")
|
||||
@@ -134,7 +134,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
|
||||
return Err(format!("bevel must be none or one of {}", names.join(", ")));
|
||||
}
|
||||
facets(id, &path, b, p).map(|_| ())
|
||||
facets(id, &path, rule, b, p).map(|_| ())
|
||||
}
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
@@ -185,11 +185,12 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
|
||||
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
|
||||
/// stroke's outline (whose contours nest as its holes do) for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
|
||||
match item {
|
||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
|
||||
_ => path.clone(),
|
||||
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
|
||||
_ => (path.clone(), rule),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +205,8 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
let mut out = vec![];
|
||||
for item in items {
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?;
|
||||
let (area, rule) = region(path, rule, item);
|
||||
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||
}
|
||||
return Some(out);
|
||||
@@ -213,7 +215,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, b, &p).ok()?;
|
||||
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,24 @@ fn random_transform_is_stable_per_object_and_within_the_values() {
|
||||
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
|
||||
}
|
||||
|
||||
/// #1705: stacked Transform effects multiply their copies; the anchors they make are bounded,
|
||||
/// whatever each one's count.
|
||||
#[test]
|
||||
fn stacked_transform_copies_stay_within_the_anchor_budget() {
|
||||
let p = json!({"copies": 1000});
|
||||
let b = square().bounds().unwrap();
|
||||
let mut path = square();
|
||||
let mut counts = vec![];
|
||||
for _ in 0..3 {
|
||||
path = crate::distort::transform_within(&path, b, &p, 0, 100);
|
||||
counts.push(path.anchor_count());
|
||||
}
|
||||
assert_eq!(counts, [100, 100, 100], "25 squares, then no room for a copy of them");
|
||||
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
|
||||
assert_eq!(crate::distort::transform_within(&square(), b, &json!({"copies": 24}), 0, 100).subpaths.len(), 25);
|
||||
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_copies_stop_before_they_overflow() {
|
||||
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
|
||||
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
trace_set_view
|
||||
),
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
|
||||
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version, null if it has none}]}", always, |_, _| {
|
||||
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -657,7 +657,10 @@ 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 base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
|
||||
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
|
||||
// (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)")))?;
|
||||
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
|
||||
@@ -738,8 +741,9 @@ fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Pain
|
||||
}
|
||||
|
||||
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
|
||||
/// inside it. Without parents, the nodes as they are.
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had:
|
||||
/// abutting paths painted in another order would show a seam where they meet. Without parents,
|
||||
/// the nodes as they are.
|
||||
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
|
||||
let n = nodes.len();
|
||||
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
@@ -761,8 +765,14 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
|
||||
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
|
||||
for &i in order.iter().rev() {
|
||||
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
|
||||
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
|
||||
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
|
||||
let mut within: Vec<(usize, Arc<Node>)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect();
|
||||
let made = if within.is_empty() {
|
||||
node
|
||||
} else {
|
||||
within.push((i, Arc::new(node)));
|
||||
within.sort_by_key(|m| m.0);
|
||||
Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect())
|
||||
};
|
||||
if let Some(slot) = built.get_mut(i) {
|
||||
*slot = Some(Arc::new(made));
|
||||
}
|
||||
@@ -778,12 +788,11 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let mut params = trace_params(p)?;
|
||||
let view = view_param(C, p)?;
|
||||
let params_json = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// The preset's name if the parameters are exactly that preset's, else "Custom".
|
||||
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
|
||||
// else "Custom".
|
||||
let named = str_param(p, "preset").unwrap_or("Default");
|
||||
let preset_label = match tr::preset(named) {
|
||||
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(¶ms_json) => named.to_string(),
|
||||
_ => "Custom".to_string(),
|
||||
};
|
||||
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(¶ms_json);
|
||||
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
|
||||
let roots = root_ids(s, p)?;
|
||||
// The image (inside a trace group, or selected directly).
|
||||
let (target, img) = {
|
||||
@@ -848,10 +857,10 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round ends and corners, as drawn lines have.
|
||||
// Traced lines: round corners, and ends round or square as the tracer found them.
|
||||
Some(width) => Appearance {
|
||||
items: vec![AppearanceItem::Stroke(StrokeLayer {
|
||||
cap: LineCap::Round,
|
||||
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
|
||||
join: LineJoin::Round,
|
||||
..StrokeLayer::new(paint, width * unit)
|
||||
})],
|
||||
|
||||
@@ -259,16 +259,23 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
|
||||
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
|
||||
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
|
||||
// the document.
|
||||
/// What is still in progress (typing, a drag) is a step of its own: keep it before the history
|
||||
/// moves, so Undo takes back only that and Redo brings it back, instead of dropping it and undoing
|
||||
/// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document
|
||||
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
|
||||
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
|
||||
/// document.
|
||||
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
if typing {
|
||||
s.set_tool_option("endTyping", &Value::Bool(true));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -286,6 +293,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let label = e.label.clone();
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) fn import(name: &str, bytes: &[u8]) -> Result<Imported> {
|
||||
Ok(d) => {
|
||||
let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 };
|
||||
b.document(&d)?;
|
||||
b.doc.limit_depth_noting(&mut b.warnings);
|
||||
let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::<Vec<_>>();
|
||||
warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
|
||||
Ok(Imported { doc: b.doc, warnings, preview_only: false })
|
||||
|
||||
@@ -13,14 +13,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// What an error rolls back besides the documents: session-level state a step may change
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
|
||||
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
|
||||
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
|
||||
// would otherwise run inside it and take its baseline as theirs (#1697).
|
||||
if s.active().is_some() {
|
||||
super::super::edit::settle_interaction(s)?;
|
||||
}
|
||||
// The documents as they are now, whole: what a failed step brings back. Their art, history,
|
||||
// file, format and saved state share their data with the open ones until a step changes them.
|
||||
let start = s.docs.clone();
|
||||
let active = s.active;
|
||||
s.batch_stash = Some(vec![]);
|
||||
let r = run_steps(s, &label, artboard, &mut cmds);
|
||||
let stash = s.batch_stash.take().unwrap_or_default();
|
||||
let results = match r {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
roll_back(s, &start, stash);
|
||||
roll_back(s, start, &stash);
|
||||
s.active = active;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
|
||||
s.pending_paint = None;
|
||||
@@ -81,22 +89,17 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// The documents as they were before the batch (`start`: their uids in order): the ones it opened
|
||||
/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone.
|
||||
fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec<DocState>) {
|
||||
let mut open = std::mem::take(&mut s.docs);
|
||||
for &uid in start {
|
||||
// The newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default();
|
||||
// Its state before the batch: the first one a step closed or replaced, else the open one.
|
||||
let st = match stash.iter().position(|d| d.uid == uid) {
|
||||
Some(i) => Some(stash.remove(i)),
|
||||
None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)),
|
||||
};
|
||||
if let Some(mut st) = st {
|
||||
st.undo_interaction();
|
||||
st.revision = newest.saturating_add(1);
|
||||
s.docs.push(st);
|
||||
}
|
||||
/// The documents as they were before the batch (`start`), whatever the steps did to them: opened
|
||||
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
|
||||
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
|
||||
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
|
||||
/// written.
|
||||
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
let open = std::mem::take(&mut s.docs);
|
||||
s.docs = start;
|
||||
for st in &mut s.docs {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.saturating_add(1);
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,8 @@ fn restore_or_import(
|
||||
/// (with a warning for each that can't be used).
|
||||
pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec<String>)> {
|
||||
let f = vectorcraft_format::load_file(bytes).map_err(err)?;
|
||||
let warnings = super::native::install_profiles(f.profiles);
|
||||
let mut warnings = super::native::install_profiles(f.profiles);
|
||||
warnings.extend(f.depth_notes);
|
||||
Ok((f.doc, f.info, warnings))
|
||||
}
|
||||
|
||||
@@ -272,12 +273,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
/// own): rasters stay linked, SVG files become art, missing ones placeholders (see
|
||||
/// [`vectorcraft_svg::ImportOptions`]).
|
||||
pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
let read = |path: &str| {
|
||||
// Only files (not folders or devices) are read.
|
||||
file_stamp(path)?;
|
||||
let bytes = read_file(path).ok()?;
|
||||
let link = crate::cmd::links::link_info(path, &bytes);
|
||||
Some((bytes, link))
|
||||
scope.clone().run(|| {
|
||||
// Only files (not folders or devices) are read.
|
||||
file_stamp(path)?;
|
||||
let bytes = read_file(path).ok()?;
|
||||
let link = crate::cmd::links::link_info(path, &bytes);
|
||||
Some((bytes, link))
|
||||
})
|
||||
};
|
||||
vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err)
|
||||
}
|
||||
@@ -418,7 +424,7 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
|
||||
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
|
||||
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
|
||||
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
|
||||
|
||||
@@ -683,6 +683,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Shortcut sets", super::shortcutset::EXTS)))
|
||||
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
@@ -159,8 +159,8 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(
|
||||
filters.len(),
|
||||
8 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
|
||||
9 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, scripts, plug-ins"
|
||||
);
|
||||
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! What names and codes in other apps' files stand for in VectorCraft, as the user confirmed them on
|
||||
//! import: a shortcut set's entry names, and its key and modifier codes. They are kept with the
|
||||
//! preferences on the user's machine, so a later import proposes them and asks only about the rest
|
||||
//! (`importMatches.*`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::Prefs;
|
||||
|
||||
/// The most matches kept.
|
||||
const MAX_MATCHES: usize = 20_000;
|
||||
/// The longest name, target or label kept (bytes).
|
||||
const MAX_TEXT: usize = 1024;
|
||||
/// The kinds of names: entry names (commands, tools and panels), and a shortcut set's key and
|
||||
/// modifier codes.
|
||||
pub const KINDS: &[&str] = &["name", "key", "modifiers"];
|
||||
|
||||
/// A name or code in another app's file and what it stands for in VectorCraft
|
||||
/// ([`crate::Prefs::import_matches`]). Missing fields read as empty, so a preferences file with a
|
||||
/// damaged match still loads.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ImportMatch {
|
||||
/// One of [`KINDS`].
|
||||
pub kind: String,
|
||||
/// As the file writes it: an entry's name, or a code in decimal (`48`).
|
||||
pub name: String,
|
||||
/// Kind `name`: a command id, `tool:<id>` or `panel:<id>`, or "" for no equivalent. Kind `key`: a
|
||||
/// key as Edit › Keyboard Shortcuts writes it (`K`, `]`, `F5`), or "" for no equivalent. Kind
|
||||
/// `modifiers`: modifiers as it writes them (`Cmd+Shift`), or "" for none.
|
||||
pub target: String,
|
||||
/// What the target was called when it was chosen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "importMatches.list",
|
||||
"Import Matches",
|
||||
[],
|
||||
None,
|
||||
"{kind?: name|key|modifiers, query?: text (in the name, target or label; any case)} the matches the user confirmed when importing other apps' files (shortcut sets), kept with the preferences → [{kind, name, target, label?}] (target \"\": no equivalent)",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
query "importMatches.set",
|
||||
"Remember Import Matches",
|
||||
[],
|
||||
None,
|
||||
"{matches: [{kind: name|key|modifiers, name: as the file writes it (codes in decimal), target: a command id, tool:<id> or panel:<id> (name), a key as Edit › Keyboard Shortcuts writes it (key), modifiers as it writes them, such as Cmd+Shift (modifiers), \"\" for no equivalent (no modifiers for kind modifiers), or null to forget the match, label?}]} remember what names and codes in another app's file stand for in VectorCraft, for later imports (at most 20,000 matches; names, targets and labels up to 1,024 bytes) → {count}",
|
||||
always,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
query "importMatches.reset",
|
||||
"Forget Import Matches",
|
||||
[],
|
||||
None,
|
||||
"{kind?: name|key|modifiers} forget the import matches, all of them or those of one kind → {removed}",
|
||||
always,
|
||||
reset
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The target the user confirmed for each kind and name.
|
||||
pub fn targets(prefs: &Prefs) -> HashMap<(&str, &str), &str> {
|
||||
let mut out = HashMap::new();
|
||||
for m in &prefs.import_matches {
|
||||
out.entry((m.kind.as_str(), m.name.as_str())).or_insert(m.target.as_str());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `kind` when it is one of [`KINDS`].
|
||||
fn kind_param(v: Option<&Value>, cmd: &str) -> Result<Option<&'static str>> {
|
||||
match v.filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(k) => k
|
||||
.as_str()
|
||||
.and_then(|k| KINDS.iter().copied().find(|x| *x == k))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("`kind` is one of {}", KINDS.join(", ")))),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = kind_param(p.get("kind"), "importMatches.list")?;
|
||||
let q = str_param(p, "query").unwrap_or_default().to_lowercase();
|
||||
let hit = |m: &ImportMatch| {
|
||||
q.is_empty() || [Some(&m.name), Some(&m.target), m.label.as_ref()].into_iter().flatten().any(|t| t.to_lowercase().contains(&q))
|
||||
};
|
||||
let rows: Vec<&ImportMatch> = s.prefs.import_matches.iter().filter(|m| kind.is_none_or(|k| m.kind == k) && hit(m)).collect();
|
||||
Ok(serde_json::to_value(rows).unwrap_or_else(|_| json!([])))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "importMatches.set";
|
||||
let rows = p.get("matches").and_then(Value::as_array).ok_or_else(|| bad(C, "give `matches`: [{kind, name, target}]"))?;
|
||||
if rows.len() > MAX_MATCHES {
|
||||
return Err(bad(C, format!("at most {MAX_MATCHES} matches are kept")));
|
||||
}
|
||||
let text = |v: Option<&Value>, what: &str| -> Result<Option<String>> {
|
||||
match v.filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(v) => {
|
||||
let t = v.as_str().ok_or_else(|| bad(C, format!("`{what}` must be text")))?;
|
||||
if t.len() > MAX_TEXT {
|
||||
return Err(bad(C, format!("`{what}` is longer than {MAX_TEXT} bytes")));
|
||||
}
|
||||
Ok(Some(t.to_string()))
|
||||
}
|
||||
}
|
||||
};
|
||||
// Checked whole before anything changes.
|
||||
let mut changes: Vec<(ImportMatch, bool)> = vec![];
|
||||
for row in rows {
|
||||
let kind = kind_param(row.get("kind"), C)?.ok_or_else(|| bad(C, "each match needs `kind`"))?;
|
||||
let name = text(row.get("name"), "name")?.filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "each match needs a `name`"))?;
|
||||
let forget = row.get("target").is_none_or(Value::is_null);
|
||||
let target = text(row.get("target"), "target")?.unwrap_or_default();
|
||||
let label = text(row.get("label"), "label")?;
|
||||
changes.push((ImportMatch { kind: kind.to_string(), name, target, label }, forget));
|
||||
}
|
||||
// Forgotten matches leave a gap until the end, so the others keep their places.
|
||||
let mut next: Vec<Option<ImportMatch>> = s.prefs.import_matches.iter().cloned().map(Some).collect();
|
||||
let mut at: HashMap<(String, String), usize> = HashMap::new();
|
||||
for (i, m) in s.prefs.import_matches.iter().enumerate() {
|
||||
at.entry((m.kind.clone(), m.name.clone())).or_insert(i);
|
||||
}
|
||||
for (m, forget) in changes {
|
||||
let key = (m.kind.clone(), m.name.clone());
|
||||
match (at.get(&key).copied(), forget) {
|
||||
(Some(i), true) => {
|
||||
if let Some(slot) = next.get_mut(i) {
|
||||
*slot = None;
|
||||
}
|
||||
at.remove(&key);
|
||||
}
|
||||
(Some(i), false) => {
|
||||
if let Some(slot) = next.get_mut(i) {
|
||||
*slot = Some(m);
|
||||
}
|
||||
}
|
||||
(None, true) => {}
|
||||
(None, false) => {
|
||||
at.insert(key, next.len());
|
||||
next.push(Some(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
let next: Vec<ImportMatch> = next.into_iter().flatten().collect();
|
||||
if next.len() > MAX_MATCHES {
|
||||
return Err(bad(C, format!("at most {MAX_MATCHES} matches are kept")));
|
||||
}
|
||||
s.prefs.import_matches = next;
|
||||
Ok(json!({ "count": s.prefs.import_matches.len() }))
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = kind_param(p.get("kind"), "importMatches.reset")?;
|
||||
let before = s.prefs.import_matches.len();
|
||||
s.prefs.import_matches.retain(|m| kind.is_some_and(|k| m.kind != k));
|
||||
Ok(json!({ "removed": before - s.prefs.import_matches.len() }))
|
||||
}
|
||||
@@ -31,6 +31,7 @@ pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
pub mod importmatches;
|
||||
pub(crate) mod inline;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
@@ -67,6 +68,7 @@ pub mod recovery;
|
||||
pub mod scripts;
|
||||
mod select;
|
||||
pub(crate) mod shaper;
|
||||
pub mod shortcutset;
|
||||
pub(crate) mod slices;
|
||||
mod stroke;
|
||||
mod style;
|
||||
@@ -283,6 +285,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(cut::specs());
|
||||
v.extend(halftone::specs());
|
||||
v.extend(perspgrid::specs());
|
||||
v.extend(shortcutset::specs());
|
||||
v.extend(importmatches::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -457,6 +461,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use vectorcraft_doc::range::parse_range;
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
|
||||
@@ -202,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Bounds",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
|
||||
has_selection,
|
||||
set_bounds
|
||||
),
|
||||
@@ -1400,6 +1400,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
if transforms_objects(p)
|
||||
&& !bool_or(p, "copy", false)
|
||||
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
|
||||
{
|
||||
return set_anchor_bounds(s, p, ids, b);
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
|
||||
// the math below runs in its frame.
|
||||
@@ -1419,15 +1426,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(v, refi);
|
||||
let mut w = f64_or(p, "width", v.width());
|
||||
let mut h = f64_or(p, "height", v.height());
|
||||
if bool_or(p, "proportional", false) {
|
||||
if p.get("width").is_some() && v.width() > 0.0 {
|
||||
h = v.height() * w / v.width();
|
||||
} else if p.get("height").is_some() && v.height() > 0.0 {
|
||||
w = v.width() * h / v.height();
|
||||
}
|
||||
}
|
||||
let (w, h) = wanted_size(p, v);
|
||||
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
|
||||
// exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the
|
||||
// scales where it misses.
|
||||
@@ -1477,6 +1476,35 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
|
||||
}
|
||||
|
||||
/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or
|
||||
/// with `proportional` follows the one given.
|
||||
fn wanted_size(p: &Value, b: Rect) -> (f64, f64) {
|
||||
let mut w = f64_or(p, "width", b.width());
|
||||
let mut h = f64_or(p, "height", b.height());
|
||||
if bool_or(p, "proportional", false) {
|
||||
if p.get("width").is_some() && b.width() > 0.0 {
|
||||
h = b.height() * w / b.width();
|
||||
} else if p.get("height").is_some() && b.height() > 0.0 {
|
||||
w = b.width() * h / b.height();
|
||||
}
|
||||
}
|
||||
(w, h)
|
||||
}
|
||||
|
||||
/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of
|
||||
/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only
|
||||
/// the anchors, their handles with them, and those objects move or scale.
|
||||
fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec<NodeId>, b: Rect) -> Result<Value> {
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(b, refi);
|
||||
let (w, h) = wanted_size(p, b);
|
||||
// A single anchor, or anchors in a line, have no size to scale on that side.
|
||||
let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 };
|
||||
let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height())));
|
||||
let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y));
|
||||
apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true)
|
||||
}
|
||||
|
||||
/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with
|
||||
/// symbol instances measured without the strokes in their art
|
||||
/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]).
|
||||
|
||||
@@ -594,7 +594,7 @@ pub(crate) fn outlined_stroke(
|
||||
st: &StrokeLayer,
|
||||
) -> Option<Node> {
|
||||
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
|
||||
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
|
||||
let mut pieces = vectorcraft_brush::stroke_pieces(d, b, &path.to_bezpath(), st);
|
||||
let art = match pieces.len() {
|
||||
0 => return None,
|
||||
1 => pieces.remove(0),
|
||||
|
||||
@@ -90,9 +90,13 @@ fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
|
||||
}
|
||||
let arrangement = po::shape_builder(&shapes, true);
|
||||
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
|
||||
// A Shaper Group's faces are what its shapes fill and what its open paths enclose: the gaps
|
||||
// closed paths enclose are the Shape Builder's only.
|
||||
let open = po::has_open_path(&shapes);
|
||||
let regions = arrangement
|
||||
.regions
|
||||
.into_iter()
|
||||
.filter(|r| open || !r.sources.is_empty())
|
||||
.filter_map(|r| {
|
||||
let bounds = r.path.bounds()?;
|
||||
let path = r.path.to_bezpath();
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Shortcut sets another app saves as text (`.kys` files that start `/Menus {`), read into their
|
||||
//! entries without changing anything (`shortcutSet.read`). Names and codes the user matched on an
|
||||
//! earlier import ([`super::importmatches`]) come back with what they stand for.
|
||||
//!
|
||||
//! The text is a tree of blocks. A slash name opens a block with `{` (a backslash in a name takes the
|
||||
//! next character as it is, so `/Acme\ Lasso\ Tool` names "Acme Lasso Tool"), and `}` closes it.
|
||||
//! Inside a block, a slash name followed by an integer sets a field: `/Context`, `/Modifiers`,
|
||||
//! `/Represent` and `/Key`. A block that sets `/Key` is an entry, and the blocks around it are its
|
||||
//! section. The layout follows the description at https://filext.com/file-extension/KYS and the
|
||||
//! example in the MIT-licensed AiCommandPalette README
|
||||
//! (https://github.com/joshbduncan/AiCommandPalette), whose entry for the 0 key holds `/Key 48` and
|
||||
//! `/Modifiers 0`. No public source says what the other codes mean, so they are read as numbers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The most bytes of a file read.
|
||||
const MAX_BYTES: usize = 4 << 20;
|
||||
/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text
|
||||
/// goes on.
|
||||
pub const READ_LIMIT: usize = MAX_BYTES + 4;
|
||||
/// The most blocks open at once.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// The most entries read.
|
||||
const MAX_ENTRIES: usize = 20_000;
|
||||
/// The longest name kept (bytes).
|
||||
const MAX_NAME: usize = 1024;
|
||||
/// The most bytes of names kept over all entries, sections included: each entry repeats the names
|
||||
/// of the blocks around it, so a small file could otherwise fill hundreds of megabytes.
|
||||
const MAX_KEPT: usize = 2 * MAX_BYTES;
|
||||
/// The most tokens listed as unread.
|
||||
const MAX_UNREAD: usize = 1000;
|
||||
/// The longest unread token shown (characters).
|
||||
const UNREAD_SHOWN: usize = 80;
|
||||
|
||||
/// What `shortcutSet.read` says about a file that isn't a text shortcut set.
|
||||
pub const NOT_READABLE: &str = "This shortcut file is in a form VectorCraft can't read.";
|
||||
|
||||
/// The extensions of shortcut sets other apps save (File › Open lists them).
|
||||
pub const EXTS: &[&str] = &["kys"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "shortcutSet.read",
|
||||
"Read Shortcut Set File",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read a shortcut set another app saved as text (.kys, starting \"/Menus {\") without changing anything: each entry is a block that sets /Key, with the names of the blocks around it as its section → {set: the file's name without its extension, entries: [{section: [names], name, key, modifiers, context, represent, line, target?, chord?}] (codes as the file writes them; null where the entry doesn't set one; target: what importMatches says the name stands for; chord: the shortcut its codes stand for, as Edit › Keyboard Shortcuts writes it, when importMatches decodes them, an entry without /Modifiers taking none), codes: {keys: [{value, count, key?}], modifiers: [{value, count, mods?}]} (key and mods from importMatches), unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (4 MiB, 16 open blocks, 20,000 entries, 8 MiB of names over the entries and their sections; names past 1,024 bytes are cut)}. A file in another form (such as XML) is an error",
|
||||
always,
|
||||
read
|
||||
)]
|
||||
}
|
||||
|
||||
/// An entry of a shortcut set: a block that sets `/Key`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct KysEntry {
|
||||
/// The names of the blocks around it, outermost first (`["Menus"]`).
|
||||
pub section: Vec<String>,
|
||||
pub name: String,
|
||||
pub key: Option<i64>,
|
||||
pub modifiers: Option<i64>,
|
||||
pub context: Option<i64>,
|
||||
pub represent: Option<i64>,
|
||||
/// The line its name is on (from 1).
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
/// A shortcut set read from text.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct KysSet {
|
||||
pub entries: Vec<KysEntry>,
|
||||
/// The tokens that weren't read, with their lines.
|
||||
pub unread: Vec<(usize, String)>,
|
||||
/// A cap stopped the reading, or cut a name.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// A token of the text.
|
||||
enum Token {
|
||||
/// A slash name, its escapes undone.
|
||||
Name(Vec<u8>),
|
||||
/// An integer; `None` when it doesn't fit an `i64`.
|
||||
Int(Option<i64>),
|
||||
Open,
|
||||
Close,
|
||||
/// Anything else, as written.
|
||||
Other(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The tokens of `text`, each with its line.
|
||||
struct Tokens<'a> {
|
||||
text: &'a [u8],
|
||||
at: usize,
|
||||
line: usize,
|
||||
}
|
||||
|
||||
impl Tokens<'_> {
|
||||
fn next_token(&mut self) -> Option<(usize, Token)> {
|
||||
// Whitespace: LF, CR and CRLF each end a line.
|
||||
while let Some(&b) = self.text.get(self.at) {
|
||||
match b {
|
||||
b'\n' => self.line += 1,
|
||||
b'\r' if self.text.get(self.at + 1) != Some(&b'\n') => self.line += 1,
|
||||
b' ' | b'\t' | b'\r' | 0x0C | 0 => {}
|
||||
_ => break,
|
||||
}
|
||||
self.at += 1;
|
||||
}
|
||||
let line = self.line;
|
||||
let b = *self.text.get(self.at)?;
|
||||
let ends = |b: u8| b.is_ascii_whitespace() || matches!(b, b'{' | b'}' | b'/' | 0);
|
||||
let token = match b {
|
||||
b'{' => {
|
||||
self.at += 1;
|
||||
Token::Open
|
||||
}
|
||||
b'}' => {
|
||||
self.at += 1;
|
||||
Token::Close
|
||||
}
|
||||
b'/' => {
|
||||
self.at += 1;
|
||||
let mut name = vec![];
|
||||
while let Some(&c) = self.text.get(self.at) {
|
||||
if c == b'\\' {
|
||||
// The next character as it is (a backslash at the end of the text is dropped).
|
||||
if let Some(&n) = self.text.get(self.at + 1) {
|
||||
name.push(n);
|
||||
if n == b'\n' || n == b'\r' && self.text.get(self.at + 2) != Some(&b'\n') {
|
||||
self.line += 1;
|
||||
}
|
||||
}
|
||||
self.at += 2;
|
||||
} else if ends(c) {
|
||||
break;
|
||||
} else {
|
||||
name.push(c);
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
Token::Name(name)
|
||||
}
|
||||
_ => {
|
||||
let start = self.at;
|
||||
while self.text.get(self.at).is_some_and(|&c| !ends(c)) {
|
||||
self.at += 1;
|
||||
}
|
||||
// Always past at least one byte.
|
||||
self.at = self.at.max(start + 1);
|
||||
let word = self.text.get(start..self.at).unwrap_or_default();
|
||||
let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word);
|
||||
if !number.is_empty() && number.iter().all(u8::is_ascii_digit) {
|
||||
Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok()))
|
||||
} else {
|
||||
Token::Other(word.to_vec())
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((line, token))
|
||||
}
|
||||
}
|
||||
|
||||
/// A block being read.
|
||||
struct Block {
|
||||
name: String,
|
||||
line: usize,
|
||||
fields: [Option<i64>; 4],
|
||||
}
|
||||
|
||||
/// The fields an entry's block sets, in [`Block::fields`] order.
|
||||
const FIELDS: [&[u8]; 4] = [b"Key", b"Modifiers", b"Context", b"Represent"];
|
||||
|
||||
/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters.
|
||||
fn name_text(name: &[u8], set: &mut KysSet) -> String {
|
||||
if name.len() > MAX_NAME {
|
||||
set.truncated = true;
|
||||
}
|
||||
let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned();
|
||||
// A character cut at the limit reads as one replacement character.
|
||||
if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') {
|
||||
s.pop();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// List a token that isn't read.
|
||||
fn unread(set: &mut KysSet, line: usize, text: impl AsRef<[u8]>) {
|
||||
if set.unread.len() < MAX_UNREAD {
|
||||
let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect();
|
||||
set.unread.push((line, text));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a shortcut set saved as text. An error when the text isn't one: it starts with `<` (XML) or
|
||||
/// doesn't start with a slash name and `{`.
|
||||
pub fn read_kys(bytes: &[u8]) -> std::result::Result<KysSet, String> {
|
||||
let mut set = KysSet::default();
|
||||
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
let text = match text.get(..MAX_BYTES) {
|
||||
Some(head) if text.len() > MAX_BYTES => {
|
||||
set.truncated = true;
|
||||
head
|
||||
}
|
||||
_ => text,
|
||||
};
|
||||
let mut tokens = Tokens { text, at: 0, line: 1 };
|
||||
// The first two tokens show what the text is.
|
||||
let mut first = Tokens { text, at: 0, line: 1 };
|
||||
match (first.next_token(), first.next_token()) {
|
||||
(Some((_, Token::Name(_))), Some((_, Token::Open))) => {}
|
||||
(Some((_, Token::Other(w))), _) if w.starts_with(b"<") => return Err(NOT_READABLE.into()),
|
||||
_ => return Err("not a shortcut set: the text doesn't start with a slash name and an opening brace".into()),
|
||||
}
|
||||
let mut stack: Vec<Block> = vec![];
|
||||
// A slash name waiting for its `{` or its integer: (line, name).
|
||||
let mut pending: Option<(usize, Vec<u8>)> = None;
|
||||
// Blocks past [`MAX_DEPTH`] being skipped.
|
||||
let mut skipped = 0usize;
|
||||
// The bytes of names the entries keep so far ([`MAX_KEPT`]).
|
||||
let mut kept = 0usize;
|
||||
while let Some((line, token)) = tokens.next_token() {
|
||||
if skipped > 0 {
|
||||
match token {
|
||||
Token::Open => skipped += 1,
|
||||
Token::Close => skipped -= 1,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match token {
|
||||
Token::Name(name) => {
|
||||
if let Some((l, n)) = pending.replace((line, name)) {
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
}
|
||||
Token::Int(value) => match pending.take() {
|
||||
Some((_, name)) if !stack.is_empty() && FIELDS.contains(&name.as_slice()) && value.is_some() => {
|
||||
let i = FIELDS.iter().position(|f| *f == name.as_slice()).unwrap_or(0);
|
||||
if let Some(slot) = stack.last_mut().and_then(|b| b.fields.get_mut(i)) {
|
||||
*slot = value;
|
||||
}
|
||||
}
|
||||
Some((l, name)) => {
|
||||
let shown = match value {
|
||||
Some(v) => format!("/{} {v}", String::from_utf8_lossy(&name)),
|
||||
None => format!("/{} (a number too large)", String::from_utf8_lossy(&name)),
|
||||
};
|
||||
unread(&mut set, l, shown);
|
||||
}
|
||||
None => unread(&mut set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())),
|
||||
},
|
||||
Token::Open => {
|
||||
let (l, name) = pending.take().unwrap_or((line, vec![]));
|
||||
if stack.len() >= MAX_DEPTH {
|
||||
set.truncated = true;
|
||||
unread(&mut set, l, format!("blocks open more than {MAX_DEPTH} deep"));
|
||||
skipped = 1;
|
||||
continue;
|
||||
}
|
||||
let name = name_text(&name, &mut set);
|
||||
stack.push(Block { name, line: l, fields: [None; 4] });
|
||||
}
|
||||
Token::Close => {
|
||||
if let Some((l, n)) = pending.take() {
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
let Some(block) = stack.pop() else {
|
||||
unread(&mut set, line, "}");
|
||||
continue;
|
||||
};
|
||||
let [key, modifiers, context, represent] = block.fields;
|
||||
if key.is_some() {
|
||||
kept += block.name.len() + stack.iter().map(|b| b.name.len()).sum::<usize>();
|
||||
if set.entries.len() >= MAX_ENTRIES || kept > MAX_KEPT {
|
||||
set.truncated = true;
|
||||
break;
|
||||
}
|
||||
let section = stack.iter().map(|b| b.name.clone()).collect();
|
||||
set.entries.push(KysEntry { section, name: block.name, key, modifiers, context, represent, line: block.line });
|
||||
}
|
||||
}
|
||||
Token::Other(word) => {
|
||||
if let Some((l, n)) = pending.take() {
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
unread(&mut set, line, word);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((l, n)) = pending {
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
for block in stack.iter().rev() {
|
||||
unread(&mut set, block.line, format!("/{} {{ (not closed)", block.name));
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Each code of `codes` with how many entries use it, the most used first.
|
||||
fn counts(codes: impl Iterator<Item = i64>) -> Vec<(i64, usize)> {
|
||||
let mut by: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
|
||||
for c in codes {
|
||||
*by.entry(c).or_default() += 1;
|
||||
}
|
||||
let mut v: Vec<(i64, usize)> = by.into_iter().collect();
|
||||
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
v
|
||||
}
|
||||
|
||||
/// The shortcut text for `mods` (`Cmd+Shift`, or "" for none) and `key`, as Edit › Keyboard
|
||||
/// Shortcuts writes it; none when the key has no equivalent.
|
||||
fn chord(mods: &str, key: &str) -> Option<String> {
|
||||
match (mods, key) {
|
||||
(_, "") => None,
|
||||
("", k) => Some(k.to_string()),
|
||||
(m, k) => Some(format!("{m}+{k}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes base64 text `b64` holds, up to [`READ_LIMIT`] and a few more: only the text they take is
|
||||
/// decoded.
|
||||
fn decode_head(b64: &str) -> Option<Vec<u8>> {
|
||||
let chars = READ_LIMIT.div_ceil(3) * 4;
|
||||
let mut kept = 0usize;
|
||||
let end = b64
|
||||
.bytes()
|
||||
.position(|b| {
|
||||
kept += usize::from(!b.is_ascii_whitespace());
|
||||
kept > chars
|
||||
})
|
||||
.unwrap_or(b64.len());
|
||||
vectorcraft_format::base64_decode(b64.get(..end)?)
|
||||
}
|
||||
|
||||
fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "shortcutSet.read";
|
||||
let bytes: std::borrow::Cow<[u8]> = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => super::fileio::read_file_head(path, READ_LIMIT)?.into(),
|
||||
(None, Some(text), _) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(),
|
||||
(None, None, Some(b64)) => decode_head(b64).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
let set = read_kys(&bytes).map_err(EngineError::Other)?;
|
||||
let file = str_param(p, "name").or_else(|| str_param(p, "path")).unwrap_or_default();
|
||||
let name = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let targets = importmatches::targets(&s.prefs);
|
||||
let saved = |kind: &str, name: &str| targets.get(&(kind, name)).copied();
|
||||
let code = |kind: &str, c: i64| saved(kind, &c.to_string());
|
||||
let entries: Vec<Value> = set
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut v = json!({
|
||||
"section": e.section, "name": e.name, "key": e.key, "modifiers": e.modifiers,
|
||||
"context": e.context, "represent": e.represent, "line": e.line,
|
||||
});
|
||||
if let Some(t) = saved("name", &e.name) {
|
||||
v["target"] = json!(t);
|
||||
}
|
||||
let mods = match e.modifiers {
|
||||
Some(m) => code("modifiers", m),
|
||||
None => Some(""),
|
||||
};
|
||||
if let Some(c) = e.key.and_then(|k| code("key", k)).zip(mods).and_then(|(k, m)| chord(m, k)) {
|
||||
v["chord"] = json!(c);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let rows = |codes: Vec<(i64, usize)>, kind: &str, field: &str| -> Vec<Value> {
|
||||
codes
|
||||
.into_iter()
|
||||
.map(|(value, count)| {
|
||||
let mut v = json!({ "value": value, "count": count });
|
||||
if let Some(t) = code(kind, value) {
|
||||
v[field] = json!(t);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
Ok(json!({
|
||||
"set": name,
|
||||
"entries": entries,
|
||||
"codes": {
|
||||
"keys": rows(counts(set.entries.iter().filter_map(|e| e.key)), "key", "key"),
|
||||
"modifiers": rows(counts(set.entries.iter().filter_map(|e| e.modifiers)), "modifiers", "mods"),
|
||||
},
|
||||
"unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::<Vec<_>>(),
|
||||
"truncated": set.truncated,
|
||||
}))
|
||||
}
|
||||
@@ -113,4 +113,38 @@ mod tests {
|
||||
s.execute("text.tabs.clear", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([]));
|
||||
}
|
||||
|
||||
/// #1877: a leader set the way the Tabs panel sets it fills the tab's gap with its dots.
|
||||
#[test]
|
||||
fn tab_leader_fills_the_gap_with_its_characters() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "AAA\t20", "size": 12})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "left", "leader": ".", "alignOn": "."}]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) else { panic!() };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let x_of = |b: usize| lay.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
|
||||
let (from, to) = (x_of(3), x_of(4));
|
||||
assert!(to - from > 150.0, "{from}..{to}");
|
||||
// Contours drawn inside the gap: only the leader's dots can be there.
|
||||
let mut dots = 0;
|
||||
for g in &lay.glyphs {
|
||||
let mut contours: Vec<kurbo::BezPath> = vec![];
|
||||
for el in g.outline.elements() {
|
||||
match (el, contours.last_mut()) {
|
||||
(kurbo::PathEl::MoveTo(_), _) | (_, None) => contours.push(kurbo::BezPath::from_vec(vec![*el])),
|
||||
(_, Some(c)) => c.push(*el),
|
||||
}
|
||||
}
|
||||
for c in contours {
|
||||
let r = kurbo::Shape::bounding_box(&c);
|
||||
if r.x0 > from - 1e-6 && r.x1 < to + 1e-6 {
|
||||
assert!(r.width() < 3.0 && r.height() < 3.0, "not a dot: {r:?}");
|
||||
dots += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(dots > 30, "{dots} dots in a {} pt gap", to - from);
|
||||
}
|
||||
}
|
||||
+56
-16
@@ -12,6 +12,7 @@ pub mod guard;
|
||||
pub mod inspect;
|
||||
pub mod journal;
|
||||
pub mod link_watch;
|
||||
pub mod namematch;
|
||||
pub mod steps;
|
||||
mod tooling;
|
||||
pub mod units;
|
||||
@@ -583,6 +584,10 @@ pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool {
|
||||
d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. }))
|
||||
}
|
||||
|
||||
/// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`].
|
||||
pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep";
|
||||
const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catalog rows) names the cap");
|
||||
|
||||
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
@@ -712,7 +717,7 @@ pub struct Prefs {
|
||||
pub canvas_color: String,
|
||||
pub auto_collapse_icon_panels: bool,
|
||||
/// User Interface › Show Tool Group Labels: the toolbar's group names (Select, Shapes, Draw…);
|
||||
/// off, a faint dash separates the groups instead (#663).
|
||||
/// off (as it ships, #1736), the tools run on from group to group with nothing between them.
|
||||
pub tool_group_labels: bool,
|
||||
pub open_documents_as_tabs: bool,
|
||||
pub large_tabs: bool,
|
||||
@@ -852,6 +857,11 @@ pub struct Prefs {
|
||||
/// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane
|
||||
/// Switching Widget shows and where (`perspective.widget.options`).
|
||||
pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions,
|
||||
/// What names and codes in other apps' shortcut sets stand for, as the user confirmed them on
|
||||
/// import (`importMatches.*`). A local library, not a Preferences dialog field: resetting the
|
||||
/// preferences keeps it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub import_matches: Vec<cmd::importmatches::ImportMatch>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -938,7 +948,7 @@ impl Default for Prefs {
|
||||
ui_brightness: s("mediumDark"),
|
||||
canvas_color: s("matchUi"),
|
||||
auto_collapse_icon_panels: false,
|
||||
tool_group_labels: true,
|
||||
tool_group_labels: false,
|
||||
open_documents_as_tabs: true,
|
||||
large_tabs: false,
|
||||
ui_scaling: 1.0,
|
||||
@@ -997,6 +1007,7 @@ impl Default for Prefs {
|
||||
perspective_presets: vec![],
|
||||
blend_options: None,
|
||||
perspective_widget: Default::default(),
|
||||
import_matches: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1225,26 +1236,24 @@ impl Session {
|
||||
format!("Untitled-{}", self.untitled_counter)
|
||||
}
|
||||
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
|
||||
/// selection boxes and hit testing would use the rough estimate until each text is edited.
|
||||
/// selection boxes and hit testing would use the rough estimate until each text is edited, and
|
||||
/// what is fitted to type's bounds (an envelope warping it) would come out differently. That
|
||||
/// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too,
|
||||
/// not only in the layers (#1738).
|
||||
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
|
||||
// Inline graphics in text take their size from their symbols' art (not saved either).
|
||||
doc.resolve_inline_art();
|
||||
let mut texts = vec![];
|
||||
doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
texts.push(n.id);
|
||||
}
|
||||
});
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind)
|
||||
&& t.cached_bounds.is_none()
|
||||
{
|
||||
cmd::typecmd::refresh_bounds(t);
|
||||
}
|
||||
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
|
||||
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
|
||||
for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) {
|
||||
refresh_tree_text_bounds(tree);
|
||||
}
|
||||
}
|
||||
/// Add a document and make it active.
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
// Every edit is checked against the document before it, so the first one is within the cap
|
||||
// (importers and the native reader have seen to theirs already, with a warning).
|
||||
doc.limit_depth();
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
// Guides from files saved before guides had layers go on a layer.
|
||||
doc.adopt_guides();
|
||||
@@ -1262,6 +1271,7 @@ impl Session {
|
||||
if index >= self.docs.len() {
|
||||
return false;
|
||||
}
|
||||
doc.limit_depth();
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
doc.adopt_guides();
|
||||
if self.active == Some(index) {
|
||||
@@ -1476,7 +1486,10 @@ impl Session {
|
||||
if !st.doc.variables.bindings.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_variable_bindings();
|
||||
}
|
||||
if doc_sane(&st.doc, &st.selection) {
|
||||
if st.doc.too_deep(Some(&before)) {
|
||||
// Deeper trees overflow the stack of what walks them.
|
||||
Err(EngineError::Other(TOO_DEEP.into()))
|
||||
} else if doc_sane(&st.doc, &st.selection) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
@@ -1693,6 +1706,31 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the
|
||||
/// nodes on the way to such type are copied.
|
||||
fn refresh_tree_text_bounds(n: &mut Arc<vectorcraft_doc::Node>) {
|
||||
fn stale(n: &vectorcraft_doc::Node) -> bool {
|
||||
matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none())
|
||||
|| n.extras().mask.as_deref().is_some_and(|m| stale(&m.art))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| stale(c)))
|
||||
}
|
||||
if !stale(n) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if let NodeKind::Text(t) = &mut n.kind
|
||||
&& t.cached_bounds.is_none()
|
||||
{
|
||||
cmd::typecmd::refresh_bounds(t);
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
|
||||
refresh_tree_text_bounds(&mut m.art);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
refresh_tree_text_bounds(c);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
@@ -1928,6 +1966,8 @@ mod tests_scripts;
|
||||
#[cfg(test)]
|
||||
mod tests_shaper;
|
||||
#[cfg(test)]
|
||||
mod tests_shortcutset;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_smartguides;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Matching a name another app's file writes (a shortcut set's entry name) against VectorCraft's
|
||||
//! commands, tools and panels, by their ids and labels only. A match is proposed when one candidate
|
||||
//! scores well and clearly ahead of the next; otherwise the best few are offered to choose from.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A command, tool or panel a name can stand for.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Candidate {
|
||||
/// `select.all`, `tool:rectangle` or `panel:layers`.
|
||||
pub id: String,
|
||||
/// Its labels: the English one, and the one in the interface language.
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a name stands for, as far as [`propose`] can tell.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Proposal {
|
||||
/// The candidate at this index of the list, with its score (0 to 1).
|
||||
Match(usize, f64),
|
||||
/// The best candidates, best first, when none stands out.
|
||||
Choice(Vec<usize>),
|
||||
}
|
||||
|
||||
/// The lowest score proposed.
|
||||
const THRESHOLD: f64 = 0.6;
|
||||
/// How far the best score must lead the next one to be proposed.
|
||||
const MARGIN: f64 = 0.1;
|
||||
/// The most candidates a [`Proposal::Choice`] lists.
|
||||
const CHOICES: usize = 5;
|
||||
/// The lowest score a [`Proposal::Choice`] lists.
|
||||
const CHOICE_FLOOR: f64 = 0.3;
|
||||
|
||||
/// `s` with only its letters and digits, lowercased.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The words of `s`, lowercased: runs of letters and digits, split where a lowercase letter or a digit
|
||||
/// meets an uppercase one (`selectAll`: `select`, `all`).
|
||||
fn words(s: &str) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut word = String::new();
|
||||
let mut prev_lower = false;
|
||||
for c in s.chars() {
|
||||
if !c.is_alphanumeric() {
|
||||
if !word.is_empty() {
|
||||
out.push(std::mem::take(&mut word));
|
||||
}
|
||||
prev_lower = false;
|
||||
continue;
|
||||
}
|
||||
if c.is_uppercase() && prev_lower && !word.is_empty() {
|
||||
out.push(std::mem::take(&mut word));
|
||||
}
|
||||
prev_lower = c.is_lowercase() || c.is_ascii_digit();
|
||||
word.extend(c.to_lowercase());
|
||||
}
|
||||
if !word.is_empty() {
|
||||
out.push(word);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The words of a candidate's id: its segments after any `tool:` or `panel:`, split as [`words`].
|
||||
fn id_words(id: &str) -> Vec<String> {
|
||||
let id = id.strip_prefix("tool:").or_else(|| id.strip_prefix("panel:")).unwrap_or(id);
|
||||
id.split('.').flat_map(words).collect()
|
||||
}
|
||||
|
||||
/// How many words `a` and `b` share, each word counted once, as a Dice coefficient (0 to 1).
|
||||
fn word_overlap(a: &[String], b: &[String]) -> f64 {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut rest: Vec<&String> = b.iter().collect();
|
||||
let mut shared = 0usize;
|
||||
for w in a {
|
||||
if let Some(i) = rest.iter().position(|x| *x == w) {
|
||||
rest.swap_remove(i);
|
||||
shared += 1;
|
||||
}
|
||||
}
|
||||
(2 * shared) as f64 / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
/// The pairs of neighboring characters in `s`.
|
||||
fn pairs(s: &str) -> Vec<(char, char)> {
|
||||
let c: Vec<char> = s.chars().collect();
|
||||
c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
|
||||
}
|
||||
|
||||
/// How many character pairs `a` and `b` (each sorted) share, as a Dice coefficient (0 to 1): for
|
||||
/// names written without spaces. Linear in their lengths, so long names in a file stay quick.
|
||||
fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let (mut i, mut j, mut shared) = (0usize, 0usize, 0usize);
|
||||
while let (Some(p), Some(q)) = (a.get(i), b.get(j)) {
|
||||
match p.cmp(q) {
|
||||
std::cmp::Ordering::Less => i += 1,
|
||||
std::cmp::Ordering::Greater => j += 1,
|
||||
std::cmp::Ordering::Equal => {
|
||||
shared += 1;
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(2 * shared) as f64 / (a.len() + b.len()) as f64
|
||||
}
|
||||
|
||||
/// A name or label as the scores compare it: its letters and digits (lowercased), its words and the
|
||||
/// character pairs of the former (sorted).
|
||||
struct Text {
|
||||
folded: String,
|
||||
words: Vec<String>,
|
||||
pairs: Vec<(char, char)>,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
fn of(s: &str) -> Self {
|
||||
let folded = folded(s);
|
||||
let mut pairs = pairs(&folded);
|
||||
pairs.sort_unstable();
|
||||
Self { pairs, words: words(s), folded }
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate as the scores compare it.
|
||||
struct Prepared {
|
||||
/// Its id's words, and those joined.
|
||||
id_words: Vec<String>,
|
||||
id: String,
|
||||
labels: Vec<Text>,
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
fn of(c: &Candidate) -> Self {
|
||||
let id_words = id_words(&c.id);
|
||||
Self { id: id_words.concat(), id_words, labels: c.labels.iter().map(|l| Text::of(l)).collect() }
|
||||
}
|
||||
|
||||
/// See [`score`].
|
||||
fn score(&self, name: &Text) -> f64 {
|
||||
if name.folded.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
if name.folded == self.id || self.labels.iter().any(|l| l.folded == name.folded) {
|
||||
return 1.0;
|
||||
}
|
||||
let by_words = self.labels.iter().map(|l| word_overlap(&name.words, &l.words)).fold(word_overlap(&name.words, &self.id_words), f64::max);
|
||||
let by_pairs = self.labels.iter().map(|l| pair_similarity(&name.pairs, &l.pairs)).fold(0.0, f64::max);
|
||||
by_words.max(by_pairs * 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// How well `name` fits `candidate` (0 to 1): 1 when it equals a label or the id, its letters and
|
||||
/// digits compared without case; else the best of word overlap and character-pair similarity, the
|
||||
/// latter counted a little lower.
|
||||
pub fn score(name: &str, candidate: &Candidate) -> f64 {
|
||||
Prepared::of(candidate).score(&Text::of(name))
|
||||
}
|
||||
|
||||
/// The candidate `name` stands for, when one scores at least 0.6 and 0.1 ahead of every other; else
|
||||
/// the best five that score at least 0.3, to choose from.
|
||||
pub fn propose(name: &str, candidates: &[Candidate]) -> Proposal {
|
||||
Matcher::new(candidates).propose(name)
|
||||
}
|
||||
|
||||
/// [`propose`] for many names against the same candidates: each candidate's words and character
|
||||
/// pairs are worked out once, and a name is scored only against the candidates that share a word or
|
||||
/// a character pair with it (the others score 0).
|
||||
pub struct Matcher {
|
||||
prepared: Vec<Prepared>,
|
||||
/// Candidates by their joined id words and their labels' letters and digits.
|
||||
exact: HashMap<String, Vec<usize>>,
|
||||
/// Candidates by a word of their id or labels.
|
||||
by_word: HashMap<String, Vec<usize>>,
|
||||
/// Candidates by a character pair of their labels.
|
||||
by_pair: HashMap<(char, char), Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
pub fn new(candidates: &[Candidate]) -> Self {
|
||||
let prepared: Vec<Prepared> = candidates.iter().map(Prepared::of).collect();
|
||||
let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new());
|
||||
fn add<K: std::hash::Hash + Eq>(map: &mut HashMap<K, Vec<usize>>, key: K, i: usize) {
|
||||
let list = map.entry(key).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
for (i, p) in prepared.iter().enumerate() {
|
||||
add(&mut exact, p.id.clone(), i);
|
||||
for l in &p.labels {
|
||||
add(&mut exact, l.folded.clone(), i);
|
||||
}
|
||||
for w in p.id_words.iter().chain(p.labels.iter().flat_map(|l| &l.words)) {
|
||||
add(&mut by_word, w.clone(), i);
|
||||
}
|
||||
for pair in p.labels.iter().flat_map(|l| &l.pairs) {
|
||||
add(&mut by_pair, *pair, i);
|
||||
}
|
||||
}
|
||||
Self { prepared, exact, by_word, by_pair }
|
||||
}
|
||||
|
||||
/// See [`propose`].
|
||||
pub fn propose(&self, name: &str) -> Proposal {
|
||||
let text = Text::of(name);
|
||||
let mut seen = vec![false; self.prepared.len()];
|
||||
let lists = self
|
||||
.exact
|
||||
.get(&text.folded)
|
||||
.into_iter()
|
||||
.chain(text.words.iter().filter_map(|w| self.by_word.get(w)))
|
||||
// Each pair looked up once (they are sorted).
|
||||
.chain(text.pairs.chunk_by(|a, b| a == b).filter_map(|c| self.by_pair.get(c.first()?)));
|
||||
let mut scored: Vec<(usize, f64)> = vec![];
|
||||
for &i in lists.flatten() {
|
||||
if let Some(s) = seen.get_mut(i)
|
||||
&& !*s
|
||||
{
|
||||
*s = true;
|
||||
let score = self.prepared.get(i).map_or(0.0, |p| p.score(&text));
|
||||
if score >= CHOICE_FLOOR {
|
||||
scored.push((i, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
match (scored.first(), scored.get(1)) {
|
||||
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
|
||||
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn candidates(list: &[(&str, &str)]) -> Vec<Candidate> {
|
||||
list.iter().map(|(id, label)| Candidate { id: id.to_string(), labels: vec![label.to_string()] }).collect()
|
||||
}
|
||||
|
||||
fn proposed<'a>(name: &str, list: &'a [Candidate]) -> Option<&'a str> {
|
||||
match propose(name, list) {
|
||||
Proposal::Match(i, _) => list.get(i).map(|c| c.id.as_str()),
|
||||
Proposal::Choice(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_words_still_find_the_tool() {
|
||||
let tools = candidates(&[
|
||||
("tool:rectangle", "Rectangle Tool"),
|
||||
("tool:roundedRectangle", "Rounded Rectangle Tool"),
|
||||
("tool:rectangularGrid", "Rectangular Grid Tool"),
|
||||
("tool:ellipse", "Ellipse Tool"),
|
||||
]);
|
||||
assert_eq!(proposed("Acme Rectangle Tool", &tools), Some("tool:rectangle"));
|
||||
assert_eq!(proposed("Rounded Rectangle", &tools), Some("tool:roundedRectangle"));
|
||||
assert_eq!(proposed("ellipse tool", &tools), Some("tool:ellipse"), "case aside, the label itself");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_only_names_match_ids() {
|
||||
let commands = candidates(&[("acme.copyAll", "Everything"), ("acme.copyAllOnPage", "Everything on This Page"), ("acme.clear", "Clear Out")]);
|
||||
assert_eq!(proposed("acmecopyall", &commands), Some("acme.copyAll"));
|
||||
assert_eq!(proposed("Acme Copy All On Page", &commands), Some("acme.copyAllOnPage"));
|
||||
assert_eq!(proposed("clear out", &commands), Some("acme.clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_need_a_choice() {
|
||||
let tools = candidates(&[("tool:rectangle", "Rectangle Tool"), ("tool:ellipse", "Ellipse Tool"), ("tool:star", "Star Tool")]);
|
||||
assert_eq!(propose("Tool", &tools), Proposal::Choice(vec![0, 1, 2]));
|
||||
assert_eq!(propose("Nothing Alike", &tools), Proposal::Choice(vec![]));
|
||||
assert_eq!(propose("", &tools), Proposal::Choice(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_without_spaces_match_by_character_pairs() {
|
||||
let list = vec![
|
||||
Candidate { id: "tool:rectangle".into(), labels: vec!["Rectangle Tool".into(), "長方形ツール".into()] },
|
||||
Candidate { id: "tool:ellipse".into(), labels: vec!["Ellipse Tool".into(), "楕円形ツール".into()] },
|
||||
];
|
||||
assert_eq!(proposed("長方形ツール", &list), Some("tool:rectangle"));
|
||||
assert_eq!(proposed("楕円形 ツール", &list), Some("tool:ellipse"), "spaces and punctuation aside");
|
||||
assert_eq!(proposed("楕円形ツール2", &list), Some("tool:ellipse"), "most character pairs shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_index_finds_what_scoring_every_candidate_finds() {
|
||||
let list = candidates(&[
|
||||
("tool:rectangle", "Rectangle Tool"),
|
||||
("tool:roundedRectangle", "Rounded Rectangle Tool"),
|
||||
("tool:ellipse", "Ellipse Tool"),
|
||||
("select.all", "All"),
|
||||
("select.none", "Deselect"),
|
||||
("edit.undo", "Undo"),
|
||||
("foo.bar", "Zzz"),
|
||||
("panel:layers", "Layers"),
|
||||
]);
|
||||
let matcher = Matcher::new(&list);
|
||||
for name in ["Acme Rectangle Tool", "rounded", "acmecopyall", "foobar", "zz", "Lay", "Undo Redo", "ツール", "", "e"] {
|
||||
let mut scored: Vec<(usize, f64)> =
|
||||
list.iter().enumerate().map(|(i, c)| (i, score(name, c))).filter(|(_, s)| *s >= CHOICE_FLOOR).collect();
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let every = match (scored.first(), scored.get(1)) {
|
||||
(Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best),
|
||||
_ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()),
|
||||
};
|
||||
assert_eq!(matcher.propose(name), every, "{name}");
|
||||
}
|
||||
assert_eq!(proposed("foobar", &list), Some("foo.bar"), "the id alone, sharing no word or pair with the label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_split_camel_case_and_separators() {
|
||||
assert_eq!(words("selectAll_onArtboard 2x"), ["select", "all", "on", "artboard", "2x"]);
|
||||
assert_eq!(words("Acme\u{a0}Lasso-Tool"), ["acme", "lasso", "tool"]);
|
||||
assert_eq!(id_words("tool:roundedRectangle"), ["rounded", "rectangle"]);
|
||||
}
|
||||
}
|
||||
@@ -821,6 +821,32 @@ fn transforms_act_on_direct_selected_anchors() {
|
||||
assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
}
|
||||
|
||||
/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H
|
||||
/// (`object.setBounds`) are the box around those anchors, and only they move or scale.
|
||||
#[test]
|
||||
fn set_bounds_acts_on_direct_selected_anchors() {
|
||||
let mut s = session();
|
||||
let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true});
|
||||
let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap());
|
||||
let points = |s: &Session| -> Vec<(f64, f64)> {
|
||||
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") };
|
||||
path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect()
|
||||
};
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"x": 130, "reference": 0})).unwrap();
|
||||
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "only the selected anchor moves");
|
||||
s.execute("object.setBounds", &json!({"y": 20, "reference": 4})).unwrap();
|
||||
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
// Two anchors: W scales their box about the reference point, the others stay.
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 50, "reference": 3})).unwrap();
|
||||
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (50.0, 100.0), (0.0, 100.0)]);
|
||||
// The path selected whole moves as a whole.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"x": 10, "reference": 0})).unwrap();
|
||||
assert_eq!(points(&s), [(10.0, 0.0), (140.0, 20.0), (60.0, 100.0), (10.0, 100.0)]);
|
||||
}
|
||||
|
||||
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
|
||||
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
|
||||
/// Current Layer on objects already on top of the current layer.
|
||||
@@ -1480,6 +1506,26 @@ fn direct_selection_moves_one_anchor() {
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
/// #1816: with Snap to Grid on, a dragged anchor lands on the grid's subdivisions, not on whole
|
||||
/// points, even with Snap to Pixel left on too.
|
||||
#[test]
|
||||
fn direct_selection_snaps_anchors_to_the_grid() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
|
||||
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
|
||||
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 101.0, 101.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 131.0, 113.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 131.0, 113.0), v).unwrap();
|
||||
let p = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().clone();
|
||||
let moved = p.subpaths[0].anchors[0].p;
|
||||
assert!(on_grid(moved.x) && on_grid(moved.y), "anchor at {moved:?}, grid step {step}");
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
|
||||
}
|
||||
|
||||
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
|
||||
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,39 @@ fn shape_builder_regions_query() {
|
||||
assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2));
|
||||
}
|
||||
|
||||
/// #1691: four circles in a 2×2 grid, each touching its neighbours, enclose a diamond nothing
|
||||
/// fills. It is a region, and a drag from a circle into it merges the two.
|
||||
#[test]
|
||||
fn shape_builder_merges_the_gap_touching_circles_enclose() {
|
||||
let mut s = session();
|
||||
let mut circles = vec![];
|
||||
for (x, y) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)] {
|
||||
let r = s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap();
|
||||
circles.push(NodeId(r["id"].as_u64().unwrap()));
|
||||
}
|
||||
set_fill(&mut s, circles[0], Color::rgb(1.0, 0.0, 0.0));
|
||||
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
let regs = r["regions"].as_array().unwrap();
|
||||
assert_eq!(regs.len(), 5, "{regs:?}");
|
||||
assert_eq!(regs.iter().filter(|x| x["sources"].as_array().unwrap().is_empty()).count(), 1, "the diamond");
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [100, 100]]})).unwrap();
|
||||
assert_eq!(r["regions"], 2);
|
||||
let out = ids(&r);
|
||||
assert_eq!(out.len(), 4, "the merged shape and the three other circles");
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
// A circle (its Bézier outline: 7,854.9) and the diamond between the four (2,145.1).
|
||||
assert!((area(&s, merged) - 10000.0).abs() < 10.0, "{}", area(&s, merged));
|
||||
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the look of the circle the drag began on");
|
||||
assert_eq!(layer_children(&s), 4);
|
||||
// A click in the diamond alone makes it a shape of its own.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 100]]})).unwrap();
|
||||
assert_eq!(ids(&r).len(), 5);
|
||||
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 2146.0).abs() < 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_drives_the_command() {
|
||||
let mut s = session();
|
||||
@@ -737,6 +770,18 @@ fn image_trace_auto_grouping_groups_what_is_inside() {
|
||||
(0..120).flat_map(|x| (0..80).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(shot(&s), shot(&flat));
|
||||
// In black and white too, where the white inside the ring is a path traced before the ring:
|
||||
// it stays under the ring in their group (over it, the ring's inner edge would show a seam).
|
||||
let bw = |grouping: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"autoGrouping": grouping}})).unwrap();
|
||||
let groups = traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).count();
|
||||
(s, groups)
|
||||
};
|
||||
let (grouped, flat) = (bw(true), bw(false));
|
||||
assert!(grouped.1 > 0 && flat.1 == 0);
|
||||
assert_eq!(shot(&grouped.0), shot(&flat.0));
|
||||
// Traced again without the option, it is flat again; expanded, the groups stay.
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).len(), 3);
|
||||
@@ -773,6 +818,29 @@ fn image_trace_gradients_fills_with_a_gradient() {
|
||||
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
|
||||
/// A preset's enhanced version: the preset with Auto Grouping and Shapes, under the preset's name.
|
||||
#[test]
|
||||
fn image_trace_enhanced_presets() {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "enhanced": true})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let stored = n.extras().trace.clone().unwrap();
|
||||
assert_eq!(stored["preset"], "6 Colors");
|
||||
assert_eq!((stored["params"]["shapes"].as_bool(), stored["params"]["autoGrouping"].as_bool()), (Some(true), Some(true)));
|
||||
// The disc of the test image is a live ellipse, in a group with nothing (it has nothing inside).
|
||||
let shapes: Vec<_> = traced_paths(&n).into_iter().filter(|c| matches!(&c.kind, NodeKind::Path { live: Some(_), .. })).collect();
|
||||
assert_eq!(shapes.len(), 1);
|
||||
// Listed with each preset; the legacy preset is as it was.
|
||||
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let six = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "6 Colors").unwrap().clone();
|
||||
assert_eq!((six["params"]["shapes"].as_bool(), six["enhanced"]["shapes"].as_bool()), (Some(false), Some(true)));
|
||||
assert_eq!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "High Fidelity Photo").unwrap()["enhanced"]["shapes"], false);
|
||||
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such", "enhanced": true})).is_err());
|
||||
// Flat Logo, a preset of our own, has no enhanced version.
|
||||
assert!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Flat Logo").unwrap()["enhanced"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_release_and_expand() {
|
||||
let mut s = session();
|
||||
@@ -874,7 +942,8 @@ fn image_trace_create_strokes_makes_stroked_centre_lines() {
|
||||
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
|
||||
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
|
||||
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
|
||||
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
|
||||
// (The line is cut square at its ends, and so is its stroke.)
|
||||
assert_eq!((st.cap, st.join), (LineCap::Square, LineJoin::Round));
|
||||
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
let sp = &line.path_data().unwrap().subpaths[0];
|
||||
assert!(!sp.closed);
|
||||
|
||||
@@ -288,3 +288,35 @@ fn enveloped_type_keeps_each_runs_colour_on_the_canvas() {
|
||||
}
|
||||
assert!(red > 100 && black > 100, "{red} red, {black} black");
|
||||
}
|
||||
|
||||
/// #1738: type warped by an envelope inside a symbol looks the same after a native save and
|
||||
/// reopen: the type in symbol definitions gets its layout bounds back (they aren't saved), so the
|
||||
/// envelope is fitted to the same box as before.
|
||||
#[test]
|
||||
fn warped_type_in_a_symbol_reopens_unchanged() {
|
||||
let mut s = session();
|
||||
s.execute("text.create", &json!({"x": 30, "y": 160, "text": "STUDIO", "size": 72, "color": "#E3AB49"})).unwrap();
|
||||
s.execute("object.envelope.makeWithWarp", &json!({"style": "wave", "bend": 18})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Master"})).unwrap();
|
||||
let render = |s: &Session| vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &Default::default());
|
||||
let before = render(&s);
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-1738-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("warped.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let d = &o.doc().unwrap().doc;
|
||||
let mut texts = 0;
|
||||
d.symbols[0].art.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
texts += 1;
|
||||
assert!(t.cached_bounds.is_some(), "the symbol's type has no layout bounds after reopening");
|
||||
}
|
||||
});
|
||||
assert_eq!(texts, 1);
|
||||
let after = render(&o);
|
||||
let differ = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count();
|
||||
assert_eq!(differ, 0, "{differ} pixels changed on reopening");
|
||||
}
|
||||
@@ -153,3 +153,29 @@ fn undo_and_redo_reuse_the_estimates() {
|
||||
let delete = st.history.undo.last().unwrap();
|
||||
assert_eq!(delete.bytes, fresh(delete, &st.doc));
|
||||
}
|
||||
|
||||
/// #1719: Redo while a drag is in progress keeps the drag as a step (which ends the steps to redo,
|
||||
/// as any new edit does) instead of swapping the document under it: before, the next preview or a
|
||||
/// cancel went back to the document from before the redo while the history said it was redone.
|
||||
#[test]
|
||||
fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() {
|
||||
let mut s = session_with_rects(1);
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
let moved = first_x(&s);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let start = first_x(&s);
|
||||
// A drag that changed nothing yet is no step: Redo redoes, and a cancel has nothing to undo.
|
||||
s.begin_interaction("Move").unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(first_x(&s), moved, "the redone move stays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A drag that moved the art is the newest step: nothing is left to redo, and nothing is lost.
|
||||
s.begin_interaction("Move").unwrap();
|
||||
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
|
||||
assert!(s.execute("edit.redo", &json!({})).is_err(), "the drag ended the steps to redo");
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(first_x(&s), start + 3.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_x(&s), start);
|
||||
}
|
||||
@@ -314,6 +314,32 @@ fn a_copy_stays_inside_the_write_root() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// An SVG's linked files are read on the importer's own thread (it needs a deep stack): the roots
|
||||
/// in force on the thread that opens the SVG hold there too.
|
||||
#[test]
|
||||
fn an_svg_reads_its_links_within_the_read_root() {
|
||||
let root = folder("svg-roots");
|
||||
for dir in ["in", "out"] {
|
||||
write(&root.join(dir).join("dot.png"), &png(4, 4, RED));
|
||||
}
|
||||
// What came of an SVG linking to the picture in `dir`: the pictures read, and the warnings.
|
||||
let import = |dir: &str| {
|
||||
let href = root.join(dir).join("dot.png").to_string_lossy().replace('\\', "/");
|
||||
let svg =
|
||||
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><image width=\"4\" height=\"4\" href=\"{href}\"/></svg>");
|
||||
let (doc, warnings) = crate::cmd::fileio::import_svg(&svg, None).unwrap();
|
||||
(doc.images.len(), warnings)
|
||||
};
|
||||
let (inside, outside) = (import("in"), import("out"));
|
||||
assert_eq!(inside, outside, "both are read with no roots in force");
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap();
|
||||
crate::file_access::confine(roots.as_ref(), || {
|
||||
assert_eq!(import("in"), inside, "inside the read root: read as before");
|
||||
assert_ne!(import("out"), outside, "outside the read root: not read");
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_search_params_never_fail_internally() {
|
||||
let root = folder("junk");
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Shortcut sets another app saved as text (`shortcutSet.read`): entries, sections, codes, the
|
||||
//! tokens left unread and the caps; and the matches kept from earlier imports (`importMatches.*`).
|
||||
//! Every set here is written for the test, with invented names.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::importmatches;
|
||||
use crate::cmd::shortcutset::{KysEntry, NOT_READABLE, read_kys};
|
||||
|
||||
fn read(text: &str) -> Value {
|
||||
Session::new().execute("shortcutSet.read", &json!({ "data": text, "name": "My Keys.kys" })).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_an_entry_with_its_codes() {
|
||||
let text = "/Menus {\n /Acme\\ Lasso\\ Tool {\n /Represent 7\n /Key 48\n /Context 2\n /Modifiers 0\n }\n}\n";
|
||||
let set = read_kys(text.as_bytes()).unwrap();
|
||||
let entry = KysEntry {
|
||||
section: vec!["Menus".into()],
|
||||
name: "Acme Lasso Tool".into(),
|
||||
key: Some(48),
|
||||
modifiers: Some(0),
|
||||
context: Some(2),
|
||||
represent: Some(7),
|
||||
line: 2,
|
||||
};
|
||||
assert_eq!((set.entries, set.unread, set.truncated), (vec![entry], vec![], false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_keep_their_sections_and_the_command_counts_codes() {
|
||||
let text = "/Menus {\n /Edit {\n /Acme\\ Undo { /Key 90 /Modifiers 64 }\n /Acme\\ Redo { /Key 90 /Modifiers 80 }\n }\n /Acme\\ Zoom { /Key 61 /Modifiers 64 }\n /Group\\ Only { /Context 1 }\n}\n";
|
||||
let r = read(text);
|
||||
assert_eq!(r["set"], "My Keys");
|
||||
let names: Vec<(&str, Vec<&str>, usize)> = r["entries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e["name"].as_str().unwrap(),
|
||||
e["section"].as_array().unwrap().iter().map(|s| s.as_str().unwrap()).collect(),
|
||||
e["line"].as_u64().unwrap() as usize,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, [("Acme Undo", vec!["Menus", "Edit"], 3), ("Acme Redo", vec!["Menus", "Edit"], 4), ("Acme Zoom", vec!["Menus"], 6)]);
|
||||
assert_eq!(r["entries"][0]["context"], Value::Null, "a code the entry doesn't set");
|
||||
assert_eq!(r["codes"]["keys"], json!([{"value": 90, "count": 2}, {"value": 61, "count": 1}]));
|
||||
assert_eq!(r["codes"]["modifiers"], json!([{"value": 64, "count": 2}, {"value": 80, "count": 1}]));
|
||||
assert_eq!((r["unread"].as_array().unwrap().len(), r["truncated"].as_bool()), (0, Some(false)), "{r}");
|
||||
// The same from a file, and from its bytes.
|
||||
let dir = vectorcraft_testkit::temp_dir("shortcutset-read");
|
||||
let path = dir.join("Studio Keys.kys");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap();
|
||||
assert_eq!((r["set"].as_str(), r["entries"].as_array().unwrap().len()), (Some("Studio Keys"), 3));
|
||||
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
|
||||
assert_eq!(s.execute("shortcutSet.read", &json!({ "dataBase64": b64 })).unwrap()["entries"].as_array().unwrap().len(), 3);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_xml_shortcut_files() {
|
||||
let e = read_kys(b"\xEF\xBB\xBF<?xml version=\"1.0\"?>\n<shortcuts/>").unwrap_err();
|
||||
assert_eq!(e, NOT_READABLE);
|
||||
assert!(read_kys(b"").unwrap_err().starts_with("not a shortcut set"));
|
||||
assert!(read_kys(b"/Menus 3").unwrap_err().starts_with("not a shortcut set"));
|
||||
let e = Session::new().execute("shortcutSet.read", &json!({ "data": "<shortcuts/>" })).unwrap_err().to_string();
|
||||
assert!(e.contains(NOT_READABLE), "{e}");
|
||||
assert!(Session::new().execute("shortcutSet.read", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_unknown_tokens_with_their_lines() {
|
||||
// CR, LF and CRLF each end a line.
|
||||
let text = "/Menus {\r/Acme\\ Tool {\r\n/Key 66 /Bogus 3 junk\n/Modifiers\n}\n/Dangling\n}\n} /Key 70";
|
||||
let set = read_kys(text.as_bytes()).unwrap();
|
||||
assert_eq!(set.entries.len(), 1);
|
||||
assert_eq!(set.entries[0].modifiers, None, "/Modifiers without a value");
|
||||
let unread: Vec<(usize, &str)> = set.unread.iter().map(|(l, t)| (*l, t.as_str())).collect();
|
||||
assert_eq!(unread, [(3, "/Bogus 3"), (3, "junk"), (4, "/Modifiers"), (6, "/Dangling"), (8, "}"), (8, "/Key 70")]);
|
||||
// A number too large, and a name ending in a backslash at the end of the text.
|
||||
let set = read_kys(b"/Menus { /A { /Key 99999999999999999999 } } /End\\").unwrap();
|
||||
assert!(set.entries.is_empty());
|
||||
assert_eq!(set.unread, [(1, "/Key (a number too large)".to_string()), (1, "/End".to_string())]);
|
||||
// A block left open.
|
||||
let set = read_kys(b"/Menus { /A { /Key 1 }").unwrap();
|
||||
assert_eq!((set.entries.len(), set.unread.clone()), (1, vec![(1, "/Menus { (not closed)".to_string())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_nesting_entries_and_names() {
|
||||
// Blocks past 16 deep are skipped, and what follows them is read.
|
||||
let deep = format!("/Menus {{ {} /Inner {{ /Key 1 }} {} /After {{ /Key 2 }} }}", "/A { ".repeat(20), "} ".repeat(20));
|
||||
let set = read_kys(deep.as_bytes()).unwrap();
|
||||
assert!(set.truncated);
|
||||
assert_eq!(set.entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["After"]);
|
||||
// At most 20,000 entries.
|
||||
let many = format!("/Menus {{ {} }}", "/E { /Key 1 } ".repeat(20_005));
|
||||
let set = read_kys(many.as_bytes()).unwrap();
|
||||
assert_eq!((set.entries.len(), set.truncated), (20_000, true));
|
||||
// A name is cut at 1,024 bytes, between characters.
|
||||
let long = format!("/Menus {{ /{} {{ /Key 1 }} }}", "é".repeat(700));
|
||||
let set = read_kys(long.as_bytes()).unwrap();
|
||||
assert_eq!((set.entries[0].name.chars().count(), set.truncated), (512, true));
|
||||
// At most 1,000 unread tokens.
|
||||
let junk = format!("/Menus {{ {} }}", "x ".repeat(1_500));
|
||||
assert_eq!(read_kys(junk.as_bytes()).unwrap().unread.len(), 1000);
|
||||
// At most 4 MiB of text.
|
||||
let big = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(4 << 20));
|
||||
let set = read_kys(big.as_bytes()).unwrap();
|
||||
assert_eq!((set.entries.len(), set.truncated), (1, true));
|
||||
// Long section names repeated for every entry stop at 8 MiB of names, not hundreds of megabytes.
|
||||
let open: String = (0..15).map(|i| format!("/{}{i} {{ ", "s".repeat(1020))).collect();
|
||||
let wide = format!("{open}{}", "/E{/Key 1}".repeat(19_000));
|
||||
let set = read_kys(wide.as_bytes()).unwrap();
|
||||
let kept: usize = set.entries.iter().map(|e| e.name.len() + e.section.iter().map(String::len).sum::<usize>()).sum();
|
||||
assert!(set.truncated && kept <= 8 << 20 && set.entries.len() > 500, "{} entries, {kept} bytes", set.entries.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_files_are_read_up_to_the_cap() {
|
||||
let text = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(5 << 20));
|
||||
let dir = vectorcraft_testkit::temp_dir("shortcutset-big");
|
||||
let path = dir.join("Big.kys");
|
||||
std::fs::write(&path, &text).unwrap();
|
||||
let b64 = vectorcraft_format::base64_encode(text.as_bytes());
|
||||
// Base64 broken into lines decodes as well.
|
||||
let lines: String = b64.as_bytes().chunks(76).map(|l| format!("{}\n", String::from_utf8_lossy(l))).collect();
|
||||
let mut s = Session::new();
|
||||
for p in [json!({ "path": path.to_string_lossy() }), json!({ "data": text }), json!({ "dataBase64": b64 }), json!({ "dataBase64": lines })] {
|
||||
let r = s.execute("shortcutSet.read", &p).unwrap();
|
||||
assert_eq!(
|
||||
(r["entries"].as_array().unwrap().len(), r["truncated"].as_bool()),
|
||||
(1, Some(true)),
|
||||
"{}",
|
||||
p.as_object().unwrap().keys().next().unwrap()
|
||||
);
|
||||
}
|
||||
// A byte order mark doesn't count toward the cap.
|
||||
let fits = format!("\u{feff}/Menus {{ /E {{ /Key 1 }} {}}}", " ".repeat((4 << 20) - 24));
|
||||
assert_eq!(fits.len(), (4 << 20) + 3);
|
||||
std::fs::write(&path, &fits).unwrap();
|
||||
assert_eq!(s.execute("shortcutSet.read", &json!({ "path": path.to_string_lossy() })).unwrap()["truncated"], false);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
fn matched(s: &mut Session, p: Value) -> Vec<String> {
|
||||
let r = s.execute("importMatches.list", &p).unwrap();
|
||||
r.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_matches_are_kept_replaced_and_forgotten() {
|
||||
let mut s = Session::new();
|
||||
let matches = json!([
|
||||
{"kind": "name", "name": "Acme Undo", "target": "edit.undo", "label": "Undo"},
|
||||
{"kind": "name", "name": "Acme Gadget", "target": ""},
|
||||
{"kind": "key", "name": "90", "target": "Z"},
|
||||
{"kind": "modifiers", "name": "64", "target": "Cmd"},
|
||||
]);
|
||||
assert_eq!(s.execute("importMatches.set", &json!({ "matches": matches })).unwrap()["count"], 4);
|
||||
assert_eq!(matched(&mut s, json!({"kind": "name"})), ["Acme Undo", "Acme Gadget"]);
|
||||
assert_eq!(matched(&mut s, json!({"query": "UNDO"})), ["Acme Undo"], "the name, target or label, any case");
|
||||
assert_eq!(matched(&mut s, json!({"query": "cmd"})), ["64"]);
|
||||
// A later answer replaces the earlier one, and null forgets it.
|
||||
let later = json!([{"kind": "name", "name": "Acme Gadget", "target": "object.group"}, {"kind": "key", "name": "90", "target": null}]);
|
||||
assert_eq!(s.execute("importMatches.set", &json!({ "matches": later })).unwrap()["count"], 3);
|
||||
let targets = importmatches::targets(&s.prefs);
|
||||
assert_eq!((targets.get(&("name", "Acme Gadget")), targets.get(&("key", "90"))), (Some(&"object.group"), None));
|
||||
// A bad match fails the whole call.
|
||||
for bad in [
|
||||
json!([{"kind": "name", "name": "Fine", "target": "edit.copy"}, {"kind": "colour", "name": "Red", "target": ""}]),
|
||||
json!([{"kind": "name", "name": "x".repeat(1025), "target": ""}]),
|
||||
json!([{"kind": "name", "name": "", "target": ""}]),
|
||||
json!([{"kind": "name", "name": "Fine", "target": 3}]),
|
||||
json!((0..20_001).map(|i| json!({"kind": "key", "name": i.to_string(), "target": "K"})).collect::<Vec<_>>()),
|
||||
] {
|
||||
assert!(s.execute("importMatches.set", &json!({ "matches": bad })).is_err());
|
||||
}
|
||||
assert!(s.execute("importMatches.list", &json!({"kind": "colour"})).is_err());
|
||||
assert_eq!(s.prefs.import_matches.len(), 3);
|
||||
// Forgetting one kind, then the rest.
|
||||
assert_eq!(s.execute("importMatches.reset", &json!({"kind": "modifiers"})).unwrap()["removed"], 1);
|
||||
assert_eq!(s.execute("importMatches.reset", &json!({})).unwrap()["removed"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_matches_survive_a_preferences_reset_and_a_restart() {
|
||||
let mut s = Session::new();
|
||||
s.execute("importMatches.set", &json!({"matches": [{"kind": "name", "name": "Acme Undo", "target": "edit.undo"}]})).unwrap();
|
||||
let saved = s.prefs.to_json();
|
||||
assert_eq!(saved["importMatches"][0]["target"], "edit.undo");
|
||||
s.execute("prefs.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.prefs.import_matches.len(), 1);
|
||||
let mut fresh = Session::new();
|
||||
fresh.apply_prefs(serde_json::from_value(saved).unwrap());
|
||||
assert_eq!(matched(&mut fresh, json!({})), ["Acme Undo"]);
|
||||
// Nothing is written while there are none, older preference files load with none, and a damaged
|
||||
// match doesn't stop the preferences loading.
|
||||
assert!(Prefs::default().to_json().get("importMatches").is_none());
|
||||
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
|
||||
assert!(old.import_matches.is_empty());
|
||||
let damaged: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2, "importMatches": [{"name": "Acme Undo"}]})).unwrap();
|
||||
assert_eq!((damaged.keyboard_increment, damaged.import_matches[0].target.as_str()), (2.0, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_shows_what_earlier_imports_matched() {
|
||||
let mut s = Session::new();
|
||||
let matches = json!([
|
||||
{"kind": "name", "name": "Acme Undo", "target": "edit.undo"},
|
||||
{"kind": "key", "name": "90", "target": "Z"},
|
||||
{"kind": "key", "name": "61", "target": ""},
|
||||
{"kind": "modifiers", "name": "64", "target": "Cmd"},
|
||||
{"kind": "modifiers", "name": "0", "target": ""},
|
||||
]);
|
||||
s.execute("importMatches.set", &json!({ "matches": matches })).unwrap();
|
||||
let text = "/Menus {\n /Acme\\ Undo { /Key 90 /Modifiers 64 }\n /Acme\\ Redo { /Key 90 /Modifiers 80 }\n /Acme\\ Zoom { /Key 61 /Modifiers 0 }\n /Acme\\ Tool { /Key 90 }\n}\n";
|
||||
let r = s.execute("shortcutSet.read", &json!({ "data": text })).unwrap();
|
||||
let e = &r["entries"];
|
||||
assert_eq!((e[0]["target"].as_str(), e[0]["chord"].as_str()), (Some("edit.undo"), Some("Cmd+Z")));
|
||||
assert_eq!((e[1].get("target"), e[1].get("chord")), (None, None), "modifiers 80 aren't matched yet");
|
||||
assert_eq!(e[2].get("chord"), None, "a key with no equivalent");
|
||||
assert_eq!(e[3]["chord"], "Z", "an entry without /Modifiers takes none");
|
||||
assert_eq!(r["codes"]["keys"], json!([{"value": 90, "count": 3, "key": "Z"}, {"value": 61, "count": 1, "key": ""}]));
|
||||
let mods = json!([{"value": 0, "count": 1, "mods": ""}, {"value": 64, "count": 1, "mods": "Cmd"}, {"value": 80, "count": 1}]);
|
||||
assert_eq!(r["codes"]["modifiers"], mods);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_take_only_the_keys_they_describe() {
|
||||
// The control channel, MCP and the CLI check params against the description (#1442).
|
||||
for (id, every_key) in [
|
||||
("shortcutSet.read", json!({"path": "/x/a.kys", "data": "", "dataBase64": "", "name": "a.kys"})),
|
||||
("importMatches.list", json!({"kind": "name", "query": "undo"})),
|
||||
("importMatches.set", json!({"matches": []})),
|
||||
("importMatches.reset", json!({"kind": "key"})),
|
||||
] {
|
||||
check_params(id, &every_key).unwrap();
|
||||
assert!(check_params(id, &json!({"qeury": "undo"})).is_err(), "{id}");
|
||||
}
|
||||
}
|
||||
@@ -353,3 +353,33 @@ fn a_group_from_a_mesh_holds_its_point_colours() {
|
||||
let names = r["swatches"].as_array().unwrap();
|
||||
assert!(names.contains(&json!("R=255 G=0 B=0")) && names.contains(&json!("R=255 G=255 B=255")), "{r}");
|
||||
}
|
||||
|
||||
/// #1735: a global swatch edit reaches opacity-mask art: the mask's colour (and with it what the
|
||||
/// mask shows) follows the swatch, and a swatch used only in a mask isn't "unused".
|
||||
#[test]
|
||||
fn global_swatch_edits_reach_opacity_mask_art() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "MaskTone", "color": "#ffffff", "global": true}));
|
||||
let obj = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [obj.0]}));
|
||||
let m = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "MaskTone", "ids": [m.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
|
||||
let mask_fill = |s: &Session| {
|
||||
let n = doc(s).node(obj).unwrap();
|
||||
n.extras().mask.as_deref().unwrap().art.appearance.fill_paint()
|
||||
};
|
||||
assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0)));
|
||||
assert!(!run(&mut s, "swatch.unused", json!({}))["names"].as_array().unwrap().iter().any(|n| n == "MaskTone"), "used by the mask");
|
||||
|
||||
let r = run(&mut s, "swatch.edit", json!({"name": "MaskTone", "color": "#000000"}));
|
||||
assert_eq!(r["relinked"], 1, "{r}");
|
||||
let fill = mask_fill(&s);
|
||||
assert_eq!(fill.color(), Some(Color::rgb(0.0, 0.0, 0.0)));
|
||||
assert!(matches!(&fill, Paint::Solid { swatch: Some(n), .. } if n == "MaskTone"), "{fill:?}");
|
||||
|
||||
// Undo brings the mask's colour back with the swatch.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0)));
|
||||
}
|
||||
@@ -150,6 +150,30 @@ fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc!");
|
||||
}
|
||||
|
||||
/// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type
|
||||
/// and keeps what was typed, and typing goes on after it.
|
||||
#[test]
|
||||
fn a_cmd_drag_while_typing_moves_the_type_and_keeps_the_typing() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("Hello", v).unwrap();
|
||||
let before = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
let (at, cmd) = (before.center(), Mods { cmd: true, ..Default::default() });
|
||||
for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 100.0), (PointerKind::Up, 100.0)] {
|
||||
s.pointer(&PointerEvent::new(kind, at.x + dx, at.y).with_mods(cmd), v).unwrap();
|
||||
}
|
||||
assert_eq!(obj(&s, id).plain_text(), "Hello", "the typing is kept");
|
||||
let moved = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
assert!(moved.x0 - before.x0 > 50.0, "the type moved: {before:?} → {moved:?}");
|
||||
s.tool_text("!", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "Hello!");
|
||||
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
assert!((after.x0 - moved.x0).abs() < 1e-6, "and stays where it was moved: {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_during_a_drag_takes_back_only_the_drag() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -115,6 +115,12 @@ impl Session {
|
||||
return *ev;
|
||||
}
|
||||
let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
|
||||
// Typing in progress is kept as its own step first: the lent tool's drag previews from the
|
||||
// document its interaction started from, which would drop the typing (#1881).
|
||||
if crate::cmd::typing_in_progress(self) {
|
||||
// Fails only without a document, and then there is no typing to keep.
|
||||
let _ = crate::cmd::settle_interaction(self);
|
||||
}
|
||||
let lent = self.make_tool(id);
|
||||
self.lender = Some(std::mem::replace(&mut self.tool, lent));
|
||||
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
|
||||
@@ -188,7 +194,9 @@ impl Session {
|
||||
guides: view.guides && !self.menu.guides_locked,
|
||||
snap_to_grid: view.snap_to_grid,
|
||||
show_bbox: view.show_bbox,
|
||||
snap_to_pixel: view.snap_to_pixel,
|
||||
// Snap to Grid and Snap to Pixel are exclusive, as in Illustrator; left both on, the grid
|
||||
// wins instead of pixels quietly rounding every drag to whole points (#1816).
|
||||
snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid,
|
||||
snap_to_point: view.snap_to_point,
|
||||
corner_widgets: view.corner_widgets,
|
||||
fill_active: self.fill_active,
|
||||
|
||||
@@ -56,6 +56,48 @@ fn legacy_profile_name_resolves_in_commands_and_files() {
|
||||
cms::set_active(&before).unwrap();
|
||||
}
|
||||
|
||||
/// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray
|
||||
/// square on the Black plate through the new working CMYK profile, and the four process plates
|
||||
/// through a proof profile loaded again under the same name, as a new renderer draws them.
|
||||
#[test]
|
||||
fn separations_preview_draws_through_new_color_settings() {
|
||||
let _g = settings();
|
||||
let mut s = session();
|
||||
let gray = rect(&mut s, 20.0, 20.0, 160.0, 120.0);
|
||||
exec(&mut s, "paint.setFill", json!({"ids": [gray.0], "color": "#808080"}));
|
||||
// The proof and Overprint Preview options the canvas takes from the view state the
|
||||
// commands set.
|
||||
let canvas = || RenderOptions {
|
||||
proof: vectorcraft_render::proof::active_proof(),
|
||||
overprint_preview: vectorcraft_render::proof::overprint_preview_on(),
|
||||
background: Some([255, 255, 255, 255]),
|
||||
..Default::default()
|
||||
};
|
||||
let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &canvas()).pixels;
|
||||
exec(&mut s, "view.separationsPreview", json!({"only": "Black"}));
|
||||
let mut r = Renderer::new();
|
||||
let before = frame(&mut r, &s);
|
||||
let old = cms::active_settings();
|
||||
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
|
||||
let fresh = frame(&mut Renderer::new(), &s);
|
||||
let warm = frame(&mut r, &s);
|
||||
cms::set_active(&old).unwrap();
|
||||
assert!(fresh != before, "the new profile changes the plate");
|
||||
assert!(warm == fresh);
|
||||
let name = "Separations Proof CMYK";
|
||||
cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some(name.into())).unwrap();
|
||||
exec(&mut s, "view.proofSetup", json!({"target": format!("cmyk:{name}")}));
|
||||
exec(&mut s, "view.separationsPreview", json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]}));
|
||||
let before = frame(&mut r, &s);
|
||||
cms::register_icc(&cms::icc_bytes(cms::DEVICE_CMYK).unwrap(), Some(name.into())).unwrap();
|
||||
let fresh = frame(&mut Renderer::new(), &s);
|
||||
let warm = frame(&mut r, &s);
|
||||
exec(&mut s, "view.proofSetup", json!({"target": "workingCmyk"}));
|
||||
exec(&mut s, "view.separationsPreview", json!({"on": false}));
|
||||
assert!(fresh != before, "the profile loaded again changes the plates");
|
||||
assert!(warm == fresh);
|
||||
}
|
||||
|
||||
/// Raster effects follow the Color Settings: a blurred CMYK fill, and a white square's drop shadow
|
||||
/// in a CMYK color, are drawn again through the new working CMYK profile, in a frame drawn while
|
||||
/// interacting (as during a dialog's preview) and after it.
|
||||
@@ -85,6 +127,41 @@ fn raster_effects_draw_through_new_color_settings() {
|
||||
cms::set_active(&old).unwrap();
|
||||
}
|
||||
|
||||
/// Pattern tiles follow the Color Settings: a fill of a pattern in a CMYK color is drawn through
|
||||
/// the new working CMYK profile in the next frame, as a renderer on a new thread (with no tiles of
|
||||
/// its own yet) draws it.
|
||||
#[test]
|
||||
fn pattern_tiles_draw_through_new_color_settings() {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_geom::shapes::rectangle;
|
||||
let _g = settings();
|
||||
let old = cms::active_settings();
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let cyan = Appearance::basic(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let square = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), cyan);
|
||||
let mut def = PatternDef::new("Checks", vec![Arc::new(square)]);
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
d.patterns.push(def);
|
||||
let checks = Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0);
|
||||
let filled = Node::path(d.alloc_id(), rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), checks);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, filled).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let mut r = Renderer::new();
|
||||
let before = frame(&mut r, &d);
|
||||
session().execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
|
||||
let warm = frame(&mut r, &d);
|
||||
let fresh = std::thread::scope(|t| t.spawn(|| frame(&mut Renderer::new(), &d)).join().unwrap());
|
||||
cms::set_active(&old).unwrap();
|
||||
assert!(fresh != before, "the new profile changes the frame");
|
||||
assert!(warm == fresh, "drawn as a renderer with no tiles draws it");
|
||||
}
|
||||
|
||||
/// Live objects follow the Color Settings: in the next frame, a blend of an RGB red and a CMYK cyan,
|
||||
/// the same blend in an envelope, an envelope that distorts the appearance of a CMYK cyan rectangle
|
||||
/// with Revolve, and a gradient mesh in CMYK colors draw through the new working CMYK profile, and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
|
||||
//! mutated raster images (Photoshop documents among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`) and flattener preset
|
||||
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
|
||||
//! (`.vcflattener`) libraries, shortcut sets other apps save as text (`.kys`), and native files (compressed, damaged, saved for older versions),
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
|
||||
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
|
||||
@@ -538,17 +538,29 @@ proptest! {
|
||||
// ---------- PDF ----------
|
||||
|
||||
fn handmade_pdf(content: &str, resources: &str, media: &str) -> Vec<u8> {
|
||||
let objs = [
|
||||
handmade_pdf_with(content, resources, media, &[])
|
||||
}
|
||||
|
||||
/// The same, followed by the objects `more` (numbered from 5).
|
||||
fn handmade_pdf_with(content: &str, resources: &str, media: &str, more: &[Vec<u8>]) -> Vec<u8> {
|
||||
let page = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources {resources} >>"),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
|
||||
];
|
||||
pdf_of(&page.into_iter().map(String::into_bytes).chain(more.iter().cloned()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// The PDF of the objects `objs` (numbered from 1, the catalog first).
|
||||
fn pdf_of(objs: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = vec![];
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
@@ -999,6 +1011,74 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- shortcut sets ----------
|
||||
|
||||
/// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an
|
||||
/// error, or a set within the caps.
|
||||
fn shortcut_set(bytes: &[u8]) -> Result<(), TestCaseError> {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
let matches = json!([
|
||||
{"kind": "name", "name": "A", "target": "edit.undo"},
|
||||
{"kind": "key", "name": "1", "target": "K"},
|
||||
{"kind": "modifiers", "name": "-1", "target": "Cmd"},
|
||||
]);
|
||||
s.execute("importMatches.set", &json!({ "matches": matches })).unwrap();
|
||||
let read = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": "fuzz.kys"});
|
||||
if let Ok(r) = no_panic(s.execute("shortcutSet.read", &read)) {
|
||||
prop_assert!(r["entries"].as_array().map_or(0, Vec::len) <= 20_000);
|
||||
prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A token of a text shortcut set: a name (escaped spaces), a field, an integer of any size and sign,
|
||||
/// a brace, a lone backslash, a line end, NUL or bytes that aren't UTF-8.
|
||||
fn arb_kys_token() -> impl Strategy<Value = Vec<u8>> {
|
||||
prop_oneof![
|
||||
"[A-Za-z ]{0,12}".prop_map(|n| format!("/{}", n.replace(' ', "\\ ")).into_bytes()),
|
||||
prop::sample::select(vec!["/Key", "/Modifiers", "/Context", "/Represent"]).prop_map(|f| f.as_bytes().to_vec()),
|
||||
any::<i64>().prop_map(|n| n.to_string().into_bytes()),
|
||||
Just(b"99999999999999999999999".to_vec()),
|
||||
prop::sample::select(vec!["{", "}", "\\", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()),
|
||||
prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn shortcut_set_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..600), head in any::<bool>()) {
|
||||
let mut data = if head { b"/Menus {".to_vec() } else { vec![] };
|
||||
data.extend(bytes);
|
||||
shortcut_set(&data)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_shortcut_sets_never_panic(tokens in prop::collection::vec(arb_kys_token(), 0..200), sep in prop::sample::select(vec![" ", "", "\t"])) {
|
||||
let mut data = b"/Menus {".to_vec();
|
||||
for t in tokens {
|
||||
data.extend(sep.as_bytes());
|
||||
data.extend(t);
|
||||
}
|
||||
shortcut_set(&data)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// A shortcut set past any cap (nesting, entries, name length, file size) is read up to it and
|
||||
/// marked truncated.
|
||||
#[test]
|
||||
fn shortcut_sets_past_the_caps_are_truncated() {
|
||||
let read = |text: String| vectorcraft_engine::Session::new().execute("shortcutSet.read", &json!({ "data": text })).unwrap();
|
||||
let deep = format!("/Menus {{ {} /K {{ /Key 1 }} }}", "/A { ".repeat(40));
|
||||
let many = format!("/Menus {{ {} }}", "/E { /Key 1 } ".repeat(20_001));
|
||||
let long = format!("/Menus {{ /{} {{ /Key 1 }} }}", "n".repeat(2000));
|
||||
let big = format!("/Menus {{ /E {{ /Key 1 }} {} }}", " ".repeat(4 << 20));
|
||||
for text in [deep, many, long, big] {
|
||||
assert_eq!(read(text)["truncated"], true);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small image of each raster format Place reads, with resolution metadata where it has some.
|
||||
fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
|
||||
let img = image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10]));
|
||||
@@ -2157,10 +2237,15 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
|
||||
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
|
||||
fn ai_sample_compressed() -> Vec<u8> {
|
||||
[&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat()
|
||||
}
|
||||
|
||||
/// `data` compressed (zlib).
|
||||
fn deflated(data: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
e.write_all(&vectorcraft_testkit::ai::sample_data()).unwrap();
|
||||
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
|
||||
e.write_all(data).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
@@ -2440,3 +2525,854 @@ proptest! {
|
||||
checked("mutated script", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "sample.jsx"}))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- deep nesting ----------
|
||||
//
|
||||
// The tree is walked by recursion, so a file nested deeply enough overflowed the stack: an abort,
|
||||
// which nothing catches. Every reader caps what it reads, and no document is deeper than
|
||||
// `MAX_DEPTH`. These run on a thread with the stack of a Windows main thread (1 MB), where an
|
||||
// overflow ends the test run.
|
||||
|
||||
use vectorcraft_doc::MAX_DEPTH;
|
||||
|
||||
/// `f` on a thread with a 1 MB stack.
|
||||
fn on_a_small_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
/// Open `bytes` as the file `name`: an error, or a document within the cap that renders and
|
||||
/// exports. Returns the document and the import's warnings.
|
||||
fn open_deep(name: &str, bytes: &[u8]) -> Option<(Document, Vec<String>)> {
|
||||
let loaded = vectorcraft_engine::cmd::fileio::load(name, bytes).ok()?;
|
||||
assert!(!loaded.doc.too_deep(None), "{name}: deeper than the cap");
|
||||
let doc = loaded.doc.clone();
|
||||
survive(name, move || Some(doc)).unwrap();
|
||||
Some((loaded.doc, loaded.warnings))
|
||||
}
|
||||
|
||||
fn merged(warnings: &[String]) -> bool {
|
||||
warnings.iter().any(|w| w.contains("nested more than"))
|
||||
}
|
||||
|
||||
fn paths_in(doc: &Document) -> usize {
|
||||
let mut paths = 0;
|
||||
doc.walk(|n| paths += usize::from(n.path_data().is_some()));
|
||||
paths
|
||||
}
|
||||
|
||||
const SVG_NS: &str = "xmlns=\"http://www.w3.org/2000/svg\"";
|
||||
|
||||
/// `n` groups in one another, each named (so it is kept) and with a rectangle beside the next.
|
||||
fn nested_svg(n: usize) -> String {
|
||||
let open: String = (0..n).map(|i| format!("<g id=\"g{i}\"><rect width=\"5\" height=\"5\"/>")).collect();
|
||||
format!("<svg {SVG_NS} width=\"100\" height=\"100\">{open}<rect width=\"10\" height=\"10\"/>{}</svg>", "</g>".repeat(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_svg_is_refused_or_merged() {
|
||||
on_a_small_stack(|| {
|
||||
// Within what the reader takes: opened, the groups past the cap merged into the last.
|
||||
let (doc, warnings) = open_deep("deep.svg", nested_svg(40).as_bytes()).expect("40 groups open");
|
||||
assert!(merged(&warnings), "{warnings:?}");
|
||||
assert_eq!(paths_in(&doc), 41, "no art is lost");
|
||||
let (_, warnings) = open_deep("shallow.svg", nested_svg(20).as_bytes()).expect("20 groups open");
|
||||
assert!(!merged(&warnings), "{warnings:?}");
|
||||
// As deep as the reader goes (on a stack of its own): opened and merged the same.
|
||||
let most = vectorcraft_svg::MAX_ELEMENT_DEPTH;
|
||||
let (doc, warnings) = open_deep("deepest.svg", nested_svg(most - 2).as_bytes()).expect("opens at the reader's cap");
|
||||
assert!(merged(&warnings), "{warnings:?}");
|
||||
assert_eq!(paths_in(&doc), most - 1, "no art is lost");
|
||||
// Past it: an error that says why, whatever the depth.
|
||||
for n in [most, 2 * most, 200_000] {
|
||||
let e = vectorcraft_engine::cmd::fileio::load("deep.svg", nested_svg(n).as_bytes()).err().expect("refused").to_string();
|
||||
assert!(e.contains(&format!("nested more than {most} deep")), "{n}: {e}");
|
||||
}
|
||||
// Unnamed groups of one object aren't kept, but usvg walks them all the same.
|
||||
let plain = format!(
|
||||
"<svg {SVG_NS} width=\"9\" height=\"9\">{}<rect width=\"1\" height=\"1\"/>{}</svg>",
|
||||
"<g>".repeat(2 * most),
|
||||
"</g>".repeat(2 * most)
|
||||
);
|
||||
assert!(open_deep("plain.svg", plain.as_bytes()).is_none());
|
||||
// A chain of `<use>`: shallow XML, a deep tree.
|
||||
let links: String = (0..3000).map(|i| format!("<g id=\"u{i}\"><use href=\"#u{}\"/></g>", i + 1)).collect();
|
||||
let chain = format!(
|
||||
"<svg {SVG_NS} width=\"9\" height=\"9\"><defs>{links}<rect id=\"u3000\" width=\"1\" height=\"1\"/></defs><use href=\"#u0\"/></svg>"
|
||||
);
|
||||
assert!(open_deep("chain.svg", chain.as_bytes()).is_none());
|
||||
// Placed and pasted, as opened.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
for n in [40, 2 * most] {
|
||||
let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(nested_svg(n).as_bytes())});
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": nested_svg(n)})));
|
||||
let _ = no_panic(s.execute("edit.paste", &json!({})));
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A page whose content is `content`, with `resources`.
|
||||
fn deep_pdf(content: &str, resources: &str) -> Vec<u8> {
|
||||
handmade_pdf(content, resources, "[0 0 100 100]")
|
||||
}
|
||||
|
||||
/// A stream object with the entries `dict`, its `data` compressed.
|
||||
fn packed(dict: &str, data: &str) -> Vec<u8> {
|
||||
packed_as_is(dict, &deflated(data.as_bytes()))
|
||||
}
|
||||
|
||||
/// A stream object with the entries `dict`, its data `z` compressed already.
|
||||
fn packed_as_is(dict: &str, z: &[u8]) -> Vec<u8> {
|
||||
// (A length the test gives stands.)
|
||||
let length = if dict.contains("/Length") { String::new() } else { format!(" /Length {}", z.len()) };
|
||||
[format!("<< {dict} /Filter /FlateDecode{length} >>\nstream\n").as_bytes(), z, b"\nendstream"].concat()
|
||||
}
|
||||
|
||||
/// A page whose content is `content`, with `resources` and the objects `more` (numbered from 5).
|
||||
fn pdf_with(content: &str, resources: &str, more: &[Vec<u8>]) -> Vec<u8> {
|
||||
handmade_pdf_with(content, resources, "[0 0 100 100]", more)
|
||||
}
|
||||
|
||||
/// Arrays in arrays, and dictionaries in dictionaries, far deeper than any reader goes: as the
|
||||
/// operands of content.
|
||||
fn nested_operands() -> [String; 2] {
|
||||
let (n, d) = (100_000, 50_000);
|
||||
[format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(n), "]".repeat(n)), format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(d), ">> ".repeat(d))]
|
||||
}
|
||||
|
||||
/// A page drawing through the compressed stream that `container` makes of each of
|
||||
/// [`nested_operands`] (object 5), one file each.
|
||||
fn nested_in(content: &str, resources: &str, container: impl Fn(&str) -> Vec<Vec<u8>>) -> Vec<Vec<u8>> {
|
||||
nested_operands().iter().map(|deep| pdf_with(content, resources, &container(deep))).collect()
|
||||
}
|
||||
|
||||
const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]";
|
||||
|
||||
/// A Type 3 font (object 5) whose glyph `a` is object 6.
|
||||
fn type3_font(resources: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 6 0 R >> \
|
||||
/Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources {resources} >>"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// A page whose box is object 6, kept with object 7 (a number) in a compressed object stream
|
||||
/// (object 5, with the entries `stream`): a file with a cross-reference stream, as it must be.
|
||||
fn pdf_with_object_stream(boxed: &str, stream: &str) -> Vec<u8> {
|
||||
let content = "0 0 5 5 re f";
|
||||
let inside = format!("{boxed} 77");
|
||||
let index = format!("6 0 7 {} ", boxed.len() + 1);
|
||||
let objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox 6 0 R /Contents 4 0 R /Resources << >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(),
|
||||
packed(&format!("/Type /ObjStm /N 2 /First {} {stream}", index.len()), &format!("{index}{inside}")),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
// One entry an object: its kind, then where it is (an offset, or the stream it is in and its
|
||||
// place there).
|
||||
let mut entries = vec![[0u8, 0, 0, 0, 0, 255, 255]];
|
||||
let at = |kind: u8, a: usize, b: u16| {
|
||||
let (a, b) = ((a as u32).to_be_bytes(), b.to_be_bytes());
|
||||
[kind, a[0], a[1], a[2], a[3], b[0], b[1]]
|
||||
};
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
entries.push(at(1, out.len(), 0));
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
entries.extend([at(2, 5, 0), at(2, 5, 1), at(1, out.len(), 0)]);
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len() * 7).as_bytes());
|
||||
out.extend_from_slice(&entries.concat());
|
||||
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// `n` objects from object 5 on, each made by `link` of the number of the next, then `last`.
|
||||
fn chain(n: usize, link: impl Fn(usize) -> String, last: &str) -> Vec<Vec<u8>> {
|
||||
(0..n).map(|i| link(i + 6).into_bytes()).chain([last.as_bytes().to_vec()]).collect()
|
||||
}
|
||||
|
||||
/// A page painting with the shading whose function is object 5.
|
||||
fn pdf_with_function(more: &[Vec<u8>]) -> Vec<u8> {
|
||||
let shading = "/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 5 0 R >> >>";
|
||||
pdf_with("/S sh", &format!("<< {shading} >>"), more)
|
||||
}
|
||||
|
||||
/// A page filling in the colour space that is object 5.
|
||||
fn pdf_with_color_space(more: &[Vec<u8>]) -> Vec<u8> {
|
||||
pdf_with("/C cs 0 sc 0 0 5 5 re f /C CS 0 SC 0 0 5 5 re S", "<< /ColorSpace << /C 5 0 R >> >>", more)
|
||||
}
|
||||
|
||||
/// Every hostile PDF here opens as an error or as a document within the cap, on a small stack.
|
||||
fn open_each(name: &str, files: impl IntoIterator<Item = Vec<u8>> + Send + 'static) {
|
||||
let name = name.to_string();
|
||||
on_a_small_stack(move || {
|
||||
for (i, bytes) in files.into_iter().enumerate() {
|
||||
open_deep(&format!("{name}-{i}.pdf"), &bytes);
|
||||
// As `.ai` (the PDF part of one) and placed, too.
|
||||
open_deep(&format!("{name}-{i}.ai"), &bytes);
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let _ = no_panic(s.execute("file.place", &json!({"name": "deep.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})));
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Nesting where only the reader of the decoded data sees it: inside each kind of compressed
|
||||
// stream a page draws through.
|
||||
|
||||
#[test]
|
||||
fn pdf_nesting_inside_a_compressed_form() {
|
||||
open_each("form", nested_in("/F Do", "<< /XObject << /F 5 0 R >> >>", |deep| vec![packed(FORM, deep)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_nesting_inside_a_compressed_pattern() {
|
||||
let tile = "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>";
|
||||
open_each("pattern", nested_in("/Pattern cs /P scn 0 0 50 50 re f", "<< /Pattern << /P 5 0 R >> >>", |deep| vec![packed(tile, deep)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_nesting_inside_a_compressed_glyph() {
|
||||
let glyph = |deep: &str| vec![type3_font("<< >>"), packed("", &format!("10 0 d0 {deep}"))];
|
||||
open_each("glyph", nested_in("BT /F1 12 Tf 10 10 Td (aa) Tj ET", "<< /Font << /F1 5 0 R >> >>", glyph));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_nesting_inside_a_compressed_soft_mask() {
|
||||
let group = format!("{FORM} /Group << /S /Transparency /CS /DeviceGray >>");
|
||||
let state = "<< /ExtGState << /G << /Type /ExtGState /SMask << /S /Luminosity /G 5 0 R >> >> >> >>";
|
||||
open_each("mask", nested_in("/G gs 0 0 50 50 re f", state, move |deep| vec![packed(&group, deep)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_nesting_inside_a_compressed_annotation() {
|
||||
let annots = "<< >> /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /AP << /N 5 0 R >> >>]";
|
||||
open_each("annotation", nested_in("0 0 5 5 re f", annots, |deep| vec![packed(FORM, deep)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_nesting_inside_compressed_page_content() {
|
||||
let files = nested_operands().map(|deep| {
|
||||
let mut objs: Vec<Vec<u8>> = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"].map(Vec::from).into();
|
||||
objs.push(b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 4 0 R] /Resources << >> >>".to_vec());
|
||||
objs.push(packed("", &deep));
|
||||
pdf_of(&objs)
|
||||
});
|
||||
open_each("content", files);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_nesting_inside_an_object_stream() {
|
||||
let (n, d) = (100_000, 50_000);
|
||||
open_each(
|
||||
"objects",
|
||||
[
|
||||
// A plain one first: the file is read as it should be.
|
||||
pdf_with_object_stream("[0 0 100 100]", ""),
|
||||
pdf_with_object_stream(&format!("{}0 0 100 100{}", "[".repeat(n), "]".repeat(n)), ""),
|
||||
pdf_with_object_stream(&format!("{}{}", "<< /A ".repeat(d), ">> ".repeat(d)), ""),
|
||||
// Its length is an object kept in the stream itself.
|
||||
pdf_with_object_stream("[0 0 100 100]", "/Length 7 0 R"),
|
||||
// It extends itself.
|
||||
pdf_with_object_stream("[0 0 100 100]", "/Extends 5 0 R"),
|
||||
],
|
||||
);
|
||||
let plain = vectorcraft_engine::cmd::fileio::load("plain.pdf", &pdf_with_object_stream("[0 0 120 80]", "")).expect("an object stream is read");
|
||||
assert_eq!(plain.doc.artboards[0].rect.width(), 120.0);
|
||||
}
|
||||
|
||||
// What refers to itself, or to the next of its kind without end.
|
||||
|
||||
#[test]
|
||||
fn pdf_page_trees_that_hold_themselves() {
|
||||
let page = |parent: usize| format!("<< /Type /Page /Parent {parent} 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << >> >>");
|
||||
let content = "<< /Length 13 >>\nstream\n0 0 5 5 re f\nendstream";
|
||||
let own = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [2 0 R 3 0 R 2 0 R] /Count 1 >>".to_string(),
|
||||
page(2),
|
||||
content.to_string(),
|
||||
];
|
||||
// Thousands of nodes, each holding the next; the page is the last.
|
||||
let n = 30_000;
|
||||
let mut deep = vec!["<< /Type /Catalog /Pages 5 0 R >>".to_string(), "null".to_string(), "null".to_string(), content.to_string()];
|
||||
deep.extend((0..n).map(|i| format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", i + 6)));
|
||||
deep.push(page(n + 4));
|
||||
// Two nodes holding each other.
|
||||
let pair = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [5 0 R] /Count 1 >>".to_string(),
|
||||
page(5),
|
||||
content.to_string(),
|
||||
"<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".to_string(),
|
||||
];
|
||||
let bytes = |objs: &[String]| pdf_of(&objs.iter().map(|o| o.clone().into_bytes()).collect::<Vec<_>>());
|
||||
open_each("pages", [bytes(&own), bytes(&deep), bytes(&pair)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_functions_that_hold_themselves() {
|
||||
let stitch = |next: usize| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>");
|
||||
let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>";
|
||||
let calculator = "/FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1]";
|
||||
let n = 100_000;
|
||||
open_each(
|
||||
"functions",
|
||||
[
|
||||
pdf_with_function(&[stitch(5).into_bytes()]),
|
||||
pdf_with_function(&[stitch(6).into_bytes(), stitch(5).into_bytes()]),
|
||||
pdf_with_function(&chain(20_000, stitch, ramp)),
|
||||
// A calculator function of procedures in procedures.
|
||||
pdf_with_function(&[packed(calculator, &format!("{}{}", "{".repeat(n), "}".repeat(n)))]),
|
||||
pdf_with_function(&[packed(calculator, &format!("{{ {} 0 0 0 {} }}", "true {".repeat(n), "} if ".repeat(n)))]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_color_spaces_that_hold_themselves() {
|
||||
let indexed = |next: usize| format!("[/Indexed {next} 0 R 1 <000000ffffff>]");
|
||||
let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 0] /N 1 >>";
|
||||
let profile = |alternate: &str| format!("<< /N 3 /Alternate {alternate} /Length 4 >>\nstream\njunk\nendstream").into_bytes();
|
||||
open_each(
|
||||
"spaces",
|
||||
[
|
||||
pdf_with_color_space(&[indexed(5).into_bytes()]),
|
||||
pdf_with_color_space(&[indexed(6).into_bytes(), indexed(5).into_bytes()]),
|
||||
pdf_with_color_space(&chain(20_000, indexed, "/DeviceRGB")),
|
||||
pdf_with_color_space(&[b"[/Separation /Ink 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]),
|
||||
pdf_with_color_space(&[b"[/DeviceN [/Ink] 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]),
|
||||
pdf_with_color_space(&[b"[/Pattern 5 0 R]".to_vec()]),
|
||||
pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("5 0 R")]),
|
||||
pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("[/ICCBased 6 0 R]")]),
|
||||
// As the space of a shading and of an image.
|
||||
pdf_with(
|
||||
"/S sh",
|
||||
"<< /Shading << /S << /ShadingType 2 /ColorSpace 5 0 R /Coords [0 0 9 0] /Function 6 0 R >> >> >>",
|
||||
&[indexed(5).into_bytes(), tint.as_bytes().to_vec()],
|
||||
),
|
||||
pdf_with(
|
||||
"q 50 0 0 50 0 0 cm /I Do Q",
|
||||
"<< /XObject << /I 6 0 R >> >>",
|
||||
&[
|
||||
indexed(5).into_bytes(),
|
||||
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace 5 0 R /Length 1 >>\nstream\n0\nendstream"
|
||||
.to_vec(),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Spaces past what a stream may decode to (256 MB), compressed (about 260 kB).
|
||||
fn spaces_past_the_cap() -> Vec<u8> {
|
||||
use std::io::Write as _;
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
for _ in 0..257 {
|
||||
e.write_all(&[b' '; 1 << 20]).unwrap();
|
||||
}
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
// Streams the reader would fail on (#1700, #1701): it applies a predictor before checking its
|
||||
// parameters (`Columns 0` divides by zero, a huge `Columns` allocates a row that large), and
|
||||
// inflates without a ceiling.
|
||||
|
||||
#[test]
|
||||
fn pdf_streams_the_reader_would_fail_on_are_refused() {
|
||||
let zero = "/DecodeParms << /Predictor 2 /Columns 0 >>";
|
||||
let huge = "/DecodeParms << /Predictor 12 /Colors 4 /Columns 99999999999 >>";
|
||||
// An inline image whose data is deflated, written in hexadecimal so the content stays text.
|
||||
let inline = |dp: &str| {
|
||||
let data: String = deflated(&[0; 8]).iter().map(|b| format!("{b:02x}")).collect();
|
||||
format!("q 10 0 0 10 0 0 cm BI /W 2 /H 2 /CS /G /BPC 8 /F [/AHx /Fl] /DP [null {dp}] ID {data}> EI Q")
|
||||
};
|
||||
let form = |dict: &str, content: &str| pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed(&format!("{FORM} {dict}"), content)]);
|
||||
let files = [
|
||||
("a form with Columns 0", form(zero, "0 0 5 5 re f")),
|
||||
("a form with a huge Columns", form(huge, "0 0 5 5 re f")),
|
||||
("an inline image with Columns 0", deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>")),
|
||||
("an inline image with a huge Columns", deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>")),
|
||||
("an inline image in a compressed form", form("", &inline("<< /Predictor 2 /Columns 0 >>"))),
|
||||
("an object stream with Columns 0", pdf_with_object_stream("[0 0 100 100]", zero)),
|
||||
("a form of spaces past the cap", pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())])),
|
||||
];
|
||||
on_a_small_stack(move || {
|
||||
for (what, bytes) in &files {
|
||||
for name in ["hostile.pdf", "hostile.ai"] {
|
||||
let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("would decode to more than 256 MB")), "{what} as {name}: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
// Within range, the same files open.
|
||||
let fine = "/DecodeParms << /Predictor 1 /Columns 0 >>";
|
||||
for bytes in [form(fine, "0 0 5 5 re f"), deep_pdf(&inline("<< /Predictor 1 >>"), "<< >>")] {
|
||||
vectorcraft_engine::cmd::fileio::load("fine.pdf", &bytes).expect("opens");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_art_that_draws_itself() {
|
||||
let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>";
|
||||
let tile = format!("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources {own}");
|
||||
let group = format!("{FORM} /Group << /S /Transparency >> /Resources {own}");
|
||||
open_each(
|
||||
"itself",
|
||||
[
|
||||
// A form drawing itself twice, a pattern painting its own tile, a glyph showing
|
||||
// itself, and a mask masked by itself.
|
||||
pdf_with("/F Do", own, &[packed(&format!("{FORM} /Resources {own}"), "0 0 5 5 re f /F Do 1 0 0 1 3 3 cm /F Do")]),
|
||||
pdf_with("/Pattern cs /P scn 0 0 50 50 re f", own, &[packed(&tile, "/Pattern cs /P scn 0 0 5 5 re f")]),
|
||||
pdf_with("BT /F1 12 Tf (a) Tj ET", own, &[type3_font(own), packed("", "10 0 d0 0 0 5 5 re f BT /F1 1 Tf (a) Tj ET")]),
|
||||
pdf_with("/G gs 0 0 50 50 re f", own, &[packed(&group, "/G gs 0 0 5 5 re f")]),
|
||||
// An image that is its own mask, and objects that are only a reference to each other.
|
||||
pdf_with("q 50 0 0 50 0 0 cm /F Do Q", own, &[
|
||||
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 5 0 R /Length 1 >>\nstream\n0\nendstream".to_vec(),
|
||||
]),
|
||||
handmade_pdf_with("0 0 5 5 re f", "5 0 R", "6 0 R", &[b"6 0 R".to_vec(), b"5 0 R".to_vec()]),
|
||||
handmade_pdf_with("0 0 5 5 re f", "<< >>", "[0 0 9 9]", &[b"<< /Length 5 0 R >>\nstream\nx\nendstream".to_vec()]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// An EPS whose program is `body`.
|
||||
fn deep_eps(body: &str) -> Vec<u8> {
|
||||
format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes()
|
||||
}
|
||||
|
||||
/// A test for each file: it opens as an error or as a document within the cap, on a small stack.
|
||||
macro_rules! deep_files {
|
||||
($($test:ident: $name:literal, $bytes:expr;)*) => {$(
|
||||
#[test]
|
||||
fn $test() {
|
||||
on_a_small_stack(|| {
|
||||
open_deep($name, &$bytes);
|
||||
});
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
deep_files! {
|
||||
// PDF: clips inside clips (saved states never restored); arrays in arrays as an operand and as
|
||||
// an object; dictionaries in dictionaries as a resource and inline; marked content never ended.
|
||||
pdf_clips_in_clips: "clips.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "q 0 0 50 50 re W n ".repeat(20_000)), "<< >>");
|
||||
pdf_arrays_in_an_operand: "arrays.pdf", deep_pdf(&format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(100_000), "]".repeat(100_000)), "<< >>");
|
||||
pdf_arrays_in_an_object: "boxes.pdf", handmade_pdf("0 0 5 5 re f", "<< >>", &format!("{}0 0 100 100{}", "[".repeat(100_000), "]".repeat(100_000)));
|
||||
pdf_dictionaries_in_resources: "dicts.pdf", deep_pdf("0 0 5 5 re f", &format!("{}{}", "<< /A ".repeat(50_000), ">> ".repeat(50_000)));
|
||||
pdf_dictionaries_in_content: "inline.pdf", deep_pdf(&format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(50_000), ">> ".repeat(50_000)), "<< >>");
|
||||
pdf_marked_content_never_ended: "marked.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "/Span << /MCID 0 >> BDC ".repeat(50_000)), "<< >>");
|
||||
// PostScript: clips inside clips; procedures in procedures; arrays in arrays; a procedure that
|
||||
// calls itself; dictionaries never ended.
|
||||
eps_clips_in_clips: "clips.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "gsave 0 0 50 50 rectclip ".repeat(20_000)));
|
||||
eps_procedures_in_procedures: "procs.eps", deep_eps(&format!("{}{} 0 0 5 5 rectfill", "{".repeat(100_000), "}".repeat(100_000)));
|
||||
eps_arrays_in_arrays: "arrays.eps", deep_eps(&format!("{}{} pop 0 0 5 5 rectfill", "[".repeat(100_000), "]".repeat(100_000)));
|
||||
eps_a_procedure_calling_itself: "runaway.eps", deep_eps("/f { 0 0 5 5 rectfill f } def f");
|
||||
eps_dictionaries_never_ended: "dicts.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "1 dict begin ".repeat(100_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_dxf_blocks_are_read_within_the_cap() {
|
||||
on_a_small_stack(|| {
|
||||
// Block `B0` inserts `B1`, which inserts `B2`, and so on; the last one holds a line.
|
||||
let pair = |c: i32, v: &str| (c, v.to_string());
|
||||
let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")];
|
||||
for i in 0..2_000 {
|
||||
pairs.extend([pair(0, "BLOCK"), pair(2, &format!("B{i}")), pair(10, "0"), pair(20, "0")]);
|
||||
if i + 1 < 2_000 {
|
||||
pairs.extend([pair(0, "INSERT"), pair(2, &format!("B{}", i + 1)), pair(10, "1"), pair(20, "1")]);
|
||||
} else {
|
||||
pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")]);
|
||||
}
|
||||
pairs.push(pair(0, "ENDBLK"));
|
||||
}
|
||||
pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES"), pair(0, "INSERT"), pair(2, "B0"), pair(10, "0"), pair(20, "0")]);
|
||||
pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "9"), pair(21, "9"), pair(0, "ENDSEC"), pair(0, "EOF")]);
|
||||
open_deep("blocks.dxf", dxf_text(&pairs).as_bytes()).expect("opens");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_blocks_inserting_themselves_are_left_out() {
|
||||
on_a_small_stack(|| {
|
||||
let pair = |c: i32, v: &str| (c, v.to_string());
|
||||
// A line, then an insert of `inserts`.
|
||||
let block = |name: &str, inserts: &str| {
|
||||
let line = [pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")];
|
||||
let insert = [pair(0, "INSERT"), pair(2, inserts), pair(10, "1"), pair(20, "1"), pair(0, "ENDBLK")];
|
||||
[pair(0, "BLOCK"), pair(2, name), pair(10, "0"), pair(20, "0")].into_iter().chain(line).chain(insert).collect::<Vec<_>>()
|
||||
};
|
||||
let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")];
|
||||
pairs.extend([block("SELF", "SELF"), block("A", "B"), block("B", "A")].concat());
|
||||
pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES")]);
|
||||
for name in ["SELF", "A"] {
|
||||
pairs.extend([pair(0, "INSERT"), pair(2, name), pair(10, "0"), pair(20, "0")]);
|
||||
}
|
||||
pairs.extend([pair(0, "ENDSEC"), pair(0, "EOF")]);
|
||||
let (_, warnings) = open_deep("self.dxf", dxf_text(&pairs).as_bytes()).expect("opens");
|
||||
assert!(warnings.iter().any(|w| w.contains("inside themselves")), "{warnings:?}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_affinity_groups_are_read_within_the_cap() {
|
||||
use vectorcraft_affinity::synth::{self, F, Method, tag};
|
||||
// Groups in groups: within what the reader takes, past its cap on layers (128), past its cap
|
||||
// on the stream's own nesting (384), and far past both.
|
||||
let files: Vec<Vec<u8>> = [20u32, 120, 180, 370, 2000]
|
||||
.into_iter()
|
||||
.map(|levels| {
|
||||
let group = |id: u32, fields| F::Def(id, vec![tag(b"Grup")], fields);
|
||||
let inner = (0..levels).fold(group(10, vec![]), |inner, i| group(11 + i, vec![(tag(b"Chld"), F::Shared(vec![inner]))]));
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![inner]))]);
|
||||
let stream = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
affinity_native(&stream, Method::Stored)
|
||||
})
|
||||
.collect();
|
||||
on_a_small_stack(move || {
|
||||
for bytes in &files {
|
||||
open_deep("deep.afdesign", bytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafiles_saving_states_without_end_are_read_within_the_cap() {
|
||||
on_a_small_stack(|| {
|
||||
// A saved state, a clip and a rectangle, over and over (nothing is ever restored).
|
||||
let longs = |v: [i32; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let emf: Vec<(u32, Vec<u8>)> = (0..50_000).flat_map(|_| [(33, vec![]), (30, longs([0, 0, 500, 500])), (43, longs([0, 0, 50, 50]))]).collect();
|
||||
open_deep("deep.emf", &emf_of(&emf));
|
||||
let shorts = |v: [i16; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>();
|
||||
let wmf: Vec<(u16, Vec<u8>)> =
|
||||
(0..20_000).flat_map(|_| [(0x001E, vec![]), (0x0416, shorts([500, 500, 0, 0])), (0x041B, shorts([50, 50, 0, 0]))]).collect();
|
||||
open_deep("deep.wmf", &wmf_of(&wmf));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_plugin_manifests_and_output_are_errors() {
|
||||
on_a_small_stack(|| {
|
||||
// (A manifest may be 64 kB.)
|
||||
let deep = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n));
|
||||
assert!(vectorcraft_plugins::Manifest::parse(deep(30_000).as_bytes()).is_err());
|
||||
assert!(vectorcraft_plugins::Manifest::parse(format!("{{\"params\": {}}}", deep(30_000)).as_bytes()).is_err());
|
||||
let deep = deep(100_000);
|
||||
for output in [deep.clone(), format!("{{\"objects\": {deep}}}"), format!("{{\"objects\": [{{\"kind\": \"path\", \"path\": {deep}}}]}}")] {
|
||||
assert!(vectorcraft_plugins::objects::decode(output.as_bytes()).is_err());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A document whose symbol `s0` and pattern `p0` are each a line stroked with the brush `loop`,
|
||||
/// with an instance of the symbol and a square painted with the pattern on the layer. The brush
|
||||
/// is the `kind`th of those that hold art (art, scatter, pattern), and its art is the `art`th of:
|
||||
/// an instance of the symbol; that instance inside as many groups as a file holds; a square
|
||||
/// painted with the pattern. So what the brush draws is drawn with the brush.
|
||||
fn brushed_document(kind: usize, art: usize) -> Document {
|
||||
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, PatternBrush, Scatter};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, PatternDef, Symbol};
|
||||
let mut doc = nested_document(1);
|
||||
let square = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 60.0, 20.0));
|
||||
let brushed = |doc: &mut Document| {
|
||||
let mut look = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)), 4.0);
|
||||
for item in &mut look.items {
|
||||
if let AppearanceItem::Stroke(s) = item {
|
||||
s.brush = Some("loop".into());
|
||||
}
|
||||
}
|
||||
Node::path(doc.alloc_id(), square(), look)
|
||||
};
|
||||
let instance =
|
||||
|doc: &mut Document| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let painted = |doc: &mut Document| {
|
||||
let fill = Paint::Pattern { pattern: "p0".into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
Node::path(doc.alloc_id(), square(), Appearance::basic(fill, Paint::None, 0.0))
|
||||
};
|
||||
let (in_symbol, in_pattern) = (brushed(&mut doc), brushed(&mut doc));
|
||||
doc.symbols.push(Symbol { name: "s0".into(), art: std::sync::Arc::new(in_symbol) });
|
||||
doc.patterns.push(PatternDef::new("p0", vec![std::sync::Arc::new(in_pattern)]));
|
||||
let art = match art {
|
||||
0 => instance(&mut doc),
|
||||
1 => (0..MAX_DEPTH + 4).fold(instance(&mut doc), |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)])),
|
||||
_ => painted(&mut doc),
|
||||
};
|
||||
let kind = match kind {
|
||||
0 => BrushKind::Art(ArtBrush { art, ..Default::default() }),
|
||||
1 => BrushKind::Scatter(Scatter { art, ..Default::default() }),
|
||||
_ => BrushKind::Pattern(PatternBrush { side: art.clone(), start: Some(art), ..Default::default() }),
|
||||
};
|
||||
vectorcraft_brush::store(&mut doc, &[Brush { name: "loop".into(), kind }]);
|
||||
let layer = doc.layers[0].id;
|
||||
for on_layer in [instance(&mut doc), painted(&mut doc), brushed(&mut doc)] {
|
||||
doc.insert(Some(layer), usize::MAX, on_layer).unwrap();
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// `doc` saved, opened again, drawn, exported and edited: nothing overflows.
|
||||
fn art_by_name_survives(what: &str, doc: Document) {
|
||||
let (back, _) = open_deep("named.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens");
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(back, None);
|
||||
for (cmd, p) in [
|
||||
("select.all", json!({})),
|
||||
("document.inspect", json!({})),
|
||||
("brush.expand", json!({})),
|
||||
("edit.undo", json!({})),
|
||||
("object.expand", json!({})),
|
||||
("edit.undo", json!({})),
|
||||
("object.outlineStroke", json!({})),
|
||||
("edit.undo", json!({})),
|
||||
("graph.design", json!({"paste": "deep"})),
|
||||
("graph.create", json!({"type": "line", "x": 10, "y": 10, "width": 80, "height": 60})),
|
||||
("graph.marker", json!({"design": "deep"})),
|
||||
("graph.design", json!({"new": "again"})),
|
||||
("edit.copy", json!({})),
|
||||
("edit.paste", json!({})),
|
||||
] {
|
||||
let _ = no_panic(s.execute(cmd, &p));
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}");
|
||||
}
|
||||
for format in ["svg", "pdf", "eps", "png", "dxf", "emf"] {
|
||||
let _ = no_panic(s.execute("document.export", &json!({"format": format})));
|
||||
}
|
||||
}
|
||||
|
||||
/// A test for each brush whose art is drawn with the brush itself ([`brushed_document`]).
|
||||
macro_rules! brushes_drawn_with_themselves {
|
||||
($($test:ident: $kind:literal, $art:literal;)*) => {$(
|
||||
#[test]
|
||||
fn $test() {
|
||||
on_a_small_stack(|| art_by_name_survives(stringify!($test), brushed_document($kind, $art)));
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
brushes_drawn_with_themselves! {
|
||||
an_art_brush_of_its_own_symbol: 0, 0;
|
||||
an_art_brush_of_its_own_symbol_in_deep_art: 0, 1;
|
||||
an_art_brush_painted_with_its_own_pattern: 0, 2;
|
||||
a_scatter_brush_of_its_own_symbol: 1, 0;
|
||||
a_scatter_brush_of_its_own_symbol_in_deep_art: 1, 1;
|
||||
a_scatter_brush_painted_with_its_own_pattern: 1, 2;
|
||||
a_pattern_brush_of_its_own_symbol: 2, 0;
|
||||
a_pattern_brush_of_its_own_symbol_in_deep_art: 2, 1;
|
||||
a_pattern_brush_painted_with_its_own_pattern: 2, 2;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graph_design_of_deep_art_and_of_a_symbol_holding_itself_is_drawn() {
|
||||
use vectorcraft_doc::{GraphDesign, Node, NodeKind};
|
||||
on_a_small_stack(|| {
|
||||
let mut doc = symbols_document(1, 1, "s0");
|
||||
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let art = (0..MAX_DEPTH + 4).fold(instance, |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)]));
|
||||
doc.graph_designs.push(GraphDesign { name: "deep".into(), art: std::sync::Arc::new(art) });
|
||||
art_by_name_survives("a graph design of deep art", doc);
|
||||
});
|
||||
}
|
||||
|
||||
/// A layer holding `levels` groups in one another around a rectangle, each with a second
|
||||
/// rectangle beside the next: `levels + 2` deep.
|
||||
fn nested_document(levels: usize) -> Document {
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||
let mut b = DocBuilder::new(100.0, 100.0);
|
||||
let rect =
|
||||
|b: &mut DocBuilder, x: f64| b.rect_node(vectorcraft_geom::Rect::new(x, 0.0, x + 10.0, 10.0), vectorcraft_color::Color::rgb8(200, 0, 0));
|
||||
let mut inner = rect(&mut b, 0.0);
|
||||
for _ in 0..levels {
|
||||
let beside = rect(&mut b, 20.0);
|
||||
inner = Node::group(b.alloc(), vec![std::sync::Arc::new(inner), std::sync::Arc::new(beside)]);
|
||||
inner.opacity = 0.9;
|
||||
}
|
||||
b.add(inner);
|
||||
b.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_at_the_cap_saves_opens_and_exports() {
|
||||
on_a_small_stack(|| {
|
||||
let doc = nested_document(MAX_DEPTH - 2);
|
||||
assert!(!doc.too_deep(None));
|
||||
let back = vectorcraft_format::load_file(&vectorcraft_format::save_file(&doc)).expect("what can be made opens again");
|
||||
assert!(back.depth_notes.is_empty());
|
||||
assert_eq!(back.doc.layers, doc.layers);
|
||||
survive("at the cap", || Some(doc.clone())).unwrap();
|
||||
// Every format that keeps the nesting reads its own file back within the cap.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(doc, None);
|
||||
for format in ["svg", "pdf", "eps", "ai"] {
|
||||
let r = s.execute("document.export", &json!({"format": format})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
open_deep(&format!("cap.{format}"), &bytes).unwrap_or_else(|| panic!("{format} opens"));
|
||||
}
|
||||
for cmd in ["select.all", "document.inspect", "document.info", "edit.copy", "edit.paste", "object.ungroup", "edit.undo"] {
|
||||
no_panic(s.execute(cmd, &json!({}))).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_file_nested_too_deep_is_merged_or_refused() {
|
||||
on_a_small_stack(|| {
|
||||
// Saved before the cap by a version that could still open it: opened, the deepest merged.
|
||||
let old = vectorcraft_format::save_file(&nested_document(MAX_DEPTH + 2));
|
||||
let (doc, warnings) = open_deep("old.vectorcraft", &old).expect("opens");
|
||||
assert!(merged(&warnings), "{warnings:?}");
|
||||
assert_eq!(paths_in(&doc), MAX_DEPTH + 3, "no art is lost");
|
||||
// Deeper than any version could open, and a hostile file: an error that says why. Written
|
||||
// by hand: a tree this deep can't be built and saved.
|
||||
let text = String::from_utf8(vectorcraft_format::save(&nested_document(0), false)).unwrap();
|
||||
let (open, close) = ("{\"id\":900000,\"kind\":{\"type\":\"group\",\"children\":[", "]}}");
|
||||
let at = text.find("\"children\":[").expect("the layer's children") + "\"children\":[".len();
|
||||
for levels in [100, 50_000] {
|
||||
let deep = format!("{}{}{},{}", &text[..at], open.repeat(levels), close.repeat(levels), &text[at..]);
|
||||
let e = vectorcraft_engine::cmd::fileio::load("deep.vectorcraft", deep.as_bytes()).err().expect("refused").to_string();
|
||||
assert!(e.contains("nested too deep"), "{levels}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_cannot_nest_deeper_than_the_cap() {
|
||||
on_a_small_stack(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let mut grouped = 0;
|
||||
for _ in 0..10_000 {
|
||||
match no_panic(s.execute("object.group", &json!({}))) {
|
||||
Ok(_) => grouped += 1,
|
||||
Err(e) => assert!(e.to_string().contains(vectorcraft_engine::TOO_DEEP), "after {grouped} groups: {e}"),
|
||||
}
|
||||
}
|
||||
// The layer, the groups and the rectangle.
|
||||
assert_eq!(grouped, MAX_DEPTH - 2);
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None));
|
||||
// Other ways down: a clipping mask, a copy pasted, a symbol made of it.
|
||||
for (cmd, p) in [
|
||||
("object.clippingMask.make", json!({})),
|
||||
("edit.copy", json!({})),
|
||||
("edit.pasteInFront", json!({})),
|
||||
("symbol.new", json!({"name": "deep"})),
|
||||
] {
|
||||
let _ = no_panic(s.execute(cmd, &p));
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd}");
|
||||
}
|
||||
survive("grouped to the cap", || Some((*s.doc().unwrap().doc).clone())).unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
/// A document whose one object is an instance of the first of `count` symbols: each symbol's art
|
||||
/// is `levels` groups around an instance of the next symbol, the last one of `last`.
|
||||
fn symbols_document(count: usize, levels: usize, last: &str) -> Document {
|
||||
use vectorcraft_doc::{Node, NodeKind, Symbol};
|
||||
let instance =
|
||||
|doc: &mut Document, of: String| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: of, xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let mut doc = nested_document(1);
|
||||
for i in 0..count {
|
||||
let mut art = instance(&mut doc, if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
|
||||
for _ in 0..levels {
|
||||
art = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(art)]);
|
||||
}
|
||||
doc.symbols.push(Symbol { name: format!("s{i}"), art: std::sync::Arc::new(art) });
|
||||
}
|
||||
let first = instance(&mut doc, "s0".into());
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// `doc` saved (as an older version, or a hostile file, could have) and opened again: within the
|
||||
/// caps (`kept`: as it was), drawn, exported and edited.
|
||||
fn symbols_survive(what: &str, doc: Document, kept: bool) {
|
||||
let (back, warnings) = open_deep("symbols.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens");
|
||||
assert_eq!(!warnings.iter().any(|w| w.contains("symbol instances")), kept, "{what}: {warnings:?}");
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(back, None);
|
||||
for cmd in [
|
||||
"select.all",
|
||||
"document.inspect",
|
||||
"document.info",
|
||||
"object.expand",
|
||||
"symbol.breakLink",
|
||||
"object.flattenTransparency",
|
||||
"edit.copy",
|
||||
"edit.paste",
|
||||
] {
|
||||
let _ = no_panic(s.execute(cmd, &json!({})));
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}");
|
||||
}
|
||||
for format in ["svg", "pdf", "eps", "png"] {
|
||||
let _ = no_panic(s.execute("document.export", &json!({"format": format})));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_holding_itself_opens_without_the_instance() {
|
||||
on_a_small_stack(|| {
|
||||
symbols_survive("a symbol holding itself", symbols_document(1, 1, "s0"), false);
|
||||
symbols_survive("symbols holding one another", symbols_document(3, 2, "s0"), false);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_in_symbols_are_drawn_only_so_deep() {
|
||||
on_a_small_stack(|| {
|
||||
// As deep as symbols draw: each one's art as deep as art goes.
|
||||
symbols_survive("symbols at the cap", symbols_document(3, MAX_DEPTH - 2, "none"), true);
|
||||
symbols_survive("symbols in symbols", symbols_document(40, MAX_DEPTH - 1, "none"), false);
|
||||
// A symbol can't be made to hold itself, or one that holds it, by editing either.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(symbols_document(2, 1, "none"), None);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
for (cmd, p) in [
|
||||
("symbol.new", json!({"name": "s1"})),
|
||||
("symbol.update", json!({"name": "s1"})),
|
||||
("symbol.update", json!({"name": "s0"})),
|
||||
("symbol.replace", json!({"name": "s1"})),
|
||||
] {
|
||||
let _ = no_panic(s.execute(cmd, &p));
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd} {p}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_painting_its_own_tile_opens_without_the_paint() {
|
||||
on_a_small_stack(|| {
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, PatternDef};
|
||||
let painted = |doc: &mut Document, with: &str| {
|
||||
let fill = Paint::Pattern { pattern: with.into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 20.0));
|
||||
vectorcraft_doc::Node::path(doc.alloc_id(), square, Appearance::basic(fill, Paint::None, 0.0))
|
||||
};
|
||||
// One pattern painting its own tile, and two painting each other's.
|
||||
for names in [["p", "p"], ["p", "q"]] {
|
||||
let mut doc = nested_document(1);
|
||||
let (first, second) = (painted(&mut doc, names[1]), painted(&mut doc, names[0]));
|
||||
doc.patterns.push(PatternDef::new(names[0], vec![std::sync::Arc::new(first)]));
|
||||
if names[0] != names[1] {
|
||||
doc.patterns.push(PatternDef::new(names[1], vec![std::sync::Arc::new(second)]));
|
||||
}
|
||||
let on_layer = painted(&mut doc, "p");
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, on_layer).unwrap();
|
||||
symbols_survive("a pattern painting its own tile", doc, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -121,6 +121,44 @@ fn batch_is_one_undo_step_and_rolls_back_on_error() {
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), 1);
|
||||
}
|
||||
|
||||
/// #1697: a failed batch brings back the whole document, not only its art: the file a Save As
|
||||
/// step named and the history an Undo step moved; and a batch leaves a drag in progress its own
|
||||
/// step.
|
||||
#[test]
|
||||
fn a_failed_batch_restores_the_file_the_history_and_a_drag_in_progress() {
|
||||
let mut s = fixtures::session();
|
||||
let rect = |x: i32| json!({"command": "shape.rectangle", "params": {"x": x, "y": 0, "width": 10, "height": 10}});
|
||||
let fail = json!({"command": "no.such.command", "params": {}});
|
||||
exec(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 10, "height": 10}));
|
||||
let before = (doc_json(&s.doc().unwrap().doc), s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
|
||||
let state = |s: &vectorcraft_engine::Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(doc_json(&st.doc), st.history.undo.len(), st.history.redo.len())
|
||||
};
|
||||
// Save As, then a failing step: the document keeps no file and stays modified.
|
||||
let path = vectorcraft_testkit::temp_dir("batch-rollback").join("named.vectorcraft").to_string_lossy().to_string();
|
||||
let save = json!({"command": "file.saveAs", "params": {"path": path}});
|
||||
assert!(s.execute("command.batch", &json!({"commands": [rect(0), save, fail]})).is_err());
|
||||
assert_eq!(state(&s), before);
|
||||
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().is_dirty()), (None, true));
|
||||
// Undo, then a failing step: the art and both history stacks are as before.
|
||||
let undo = json!({"command": "edit.undo", "params": {}});
|
||||
assert!(s.execute("command.batch", &json!({"commands": [undo, fail]})).is_err());
|
||||
assert_eq!(state(&s), before);
|
||||
// A batch during a drag: the drag is kept as a step, the batch is the next one.
|
||||
exec(&mut s, "select.all", json!({}));
|
||||
s.begin_interaction("Move").unwrap();
|
||||
s.preview("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
let moved = doc_json(&s.doc().unwrap().doc);
|
||||
exec(&mut s, "command.batch", json!({"commands": [rect(100)]}));
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 2);
|
||||
exec(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(doc_json(&s.doc().unwrap().doc), moved, "undoing the batch leaves the drag's move");
|
||||
exec(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(doc_json(&s.doc().unwrap().doc), before.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_batch_is_harmless() {
|
||||
let mut s = fixtures::session();
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! Every operation counts against a budget and procedures nest only so deep, so a looping or
|
||||
//! recursing program ends with [`PsError::Limit`] instead of hanging.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use vectorcraft_geom::Affine;
|
||||
@@ -30,6 +29,8 @@ pub(crate) const MAX_GSAVE: usize = 1000;
|
||||
const MAX_MEMORY: usize = 1 << 30;
|
||||
|
||||
pub(crate) struct Interp<'a> {
|
||||
/// The list of the arrays and dictionaries made since this started ([`super::obj::track`]).
|
||||
made: u64,
|
||||
pub lex: Lexer<'a>,
|
||||
pub stack: Vec<Obj>,
|
||||
pub dicts: Vec<DictRef>,
|
||||
@@ -86,7 +87,7 @@ struct SaveMark {
|
||||
}
|
||||
|
||||
fn new_dict() -> DictRef {
|
||||
Rc::new(RefCell::new(Dict::new()))
|
||||
DictRef::new(Dict::new())
|
||||
}
|
||||
|
||||
/// An encoding vector: 256 `/.notdef` names (type is read as Latin-1 whatever the encoding).
|
||||
@@ -102,6 +103,7 @@ const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Fo
|
||||
|
||||
impl<'a> Interp<'a> {
|
||||
pub fn new(src: &'a [u8], g: GState, out: Out) -> Self {
|
||||
let made = super::obj::track();
|
||||
let system = new_dict();
|
||||
{
|
||||
let mut d = system.borrow_mut();
|
||||
@@ -141,6 +143,7 @@ impl<'a> Interp<'a> {
|
||||
system.borrow_mut().insert(Key::name(name), Obj::Dict(d.clone()));
|
||||
}
|
||||
Self {
|
||||
made,
|
||||
lex: Lexer::new(src),
|
||||
stack: vec![],
|
||||
dicts: vec![system.clone(), global.clone(), user.clone()],
|
||||
@@ -206,6 +209,8 @@ impl<'a> Interp<'a> {
|
||||
self.dicts.clear();
|
||||
self.stack.clear();
|
||||
self.journal.clear();
|
||||
// What the program linked into itself, reachable or not.
|
||||
super::obj::release_tracked(self.made);
|
||||
}
|
||||
|
||||
// ---------- the operand stack ----------
|
||||
|
||||
@@ -268,5 +268,7 @@ fn finish_with(out: Out, layers: Vec<Arc<Node>>) -> Imported {
|
||||
}
|
||||
}
|
||||
doc.layers = layers;
|
||||
Imported { document: doc, warnings: out.warnings, preview: false }
|
||||
let mut warnings = out.warnings;
|
||||
doc.limit_depth_noting(&mut warnings);
|
||||
Imported { document: doc, warnings, preview: false }
|
||||
}
|
||||
@@ -993,6 +993,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr
|
||||
}
|
||||
None => unmark(&mut doc.layers, true, &mut vec![]),
|
||||
}
|
||||
doc.limit_depth_noting(&mut notes);
|
||||
Ok((doc, notes))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::cell::{Ref, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::rc::Rc;
|
||||
use std::rc::{Rc, Weak};
|
||||
|
||||
use super::data::Stream;
|
||||
use super::graphics::GState;
|
||||
@@ -12,7 +12,163 @@ use super::graphics::GState;
|
||||
pub(crate) type Dict = HashMap<Key, Obj>;
|
||||
|
||||
/// A shared, mutable dictionary.
|
||||
pub(crate) type DictRef = Rc<RefCell<Dict>>;
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DictRef(Rc<RefCell<Dict>>);
|
||||
|
||||
impl DictRef {
|
||||
pub fn new(d: Dict) -> Self {
|
||||
let d = Rc::new(RefCell::new(d));
|
||||
note(Made::Dict(Rc::downgrade(&d)));
|
||||
Self(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// An array or a dictionary a program made.
|
||||
enum Made {
|
||||
Array(Weak<RefCell<Vec<Obj>>>),
|
||||
Dict(Weak<RefCell<Dict>>),
|
||||
}
|
||||
|
||||
/// The arrays and dictionaries made on this thread since [`track`], for [`release_tracked`].
|
||||
struct Tracked {
|
||||
owner: u64,
|
||||
made: Vec<Made>,
|
||||
/// The list is cleared of dropped objects when it reaches this length.
|
||||
sweep_at: usize,
|
||||
}
|
||||
|
||||
const SWEEP_MIN: usize = 4096;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: the arrays of the list last released on this thread that were still alive after it
|
||||
/// (the ones the interpreter itself still held).
|
||||
pub(crate) static ARRAYS_LEFT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TRACKED: RefCell<Option<Tracked>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Start listing the arrays and dictionaries made on this thread (an interpreter starting) → the
|
||||
/// list's owner, for [`release_tracked`]. A list already there is dropped: its objects are freed
|
||||
/// as before this existed, unless they hold each other.
|
||||
pub(crate) fn track() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
let owner = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
// (Nothing to list on when the thread is ending.)
|
||||
let _ = TRACKED.try_with(|t| t.try_borrow_mut().map(|mut t| *t = Some(Tracked { owner, made: vec![], sweep_at: SWEEP_MIN })));
|
||||
owner
|
||||
}
|
||||
|
||||
fn note(m: Made) {
|
||||
let _ = TRACKED.try_with(|t| {
|
||||
let Ok(mut t) = t.try_borrow_mut() else { return };
|
||||
let Some(t) = t.as_mut() else { return };
|
||||
if t.made.len() >= t.sweep_at {
|
||||
// Twice what is alive at most, so the list costs a program no more than its objects.
|
||||
t.made.retain(|m| match m {
|
||||
Made::Array(a) => a.strong_count() > 0,
|
||||
Made::Dict(d) => d.strong_count() > 0,
|
||||
});
|
||||
t.sweep_at = (t.made.len() * 2).max(SWEEP_MIN);
|
||||
}
|
||||
t.made.push(m);
|
||||
});
|
||||
}
|
||||
|
||||
/// Empty every array and dictionary still alive of the list `owner` started (the interpreter is
|
||||
/// done with them), so those holding themselves or each other are freed too: reference counts
|
||||
/// alone never free `/a 1 array def a 0 a put`, and a program can leave as much of that behind as
|
||||
/// it may allocate, on every import (#1714).
|
||||
pub(crate) fn release_tracked(owner: u64) {
|
||||
let made = TRACKED.try_with(|t| {
|
||||
let mut t = t.try_borrow_mut().ok()?;
|
||||
t.take_if(|t| t.owner == owner).map(|t| t.made)
|
||||
});
|
||||
let made = made.ok().flatten().unwrap_or_default();
|
||||
#[cfg(test)]
|
||||
let arrays: Vec<_> = made.iter().filter_map(|m| if let Made::Array(a) = m { Some(a.clone()) } else { None }).collect();
|
||||
for m in made {
|
||||
// What it holds is taken out first and dropped with nothing borrowed.
|
||||
match m {
|
||||
Made::Array(a) => {
|
||||
let held = a.upgrade().and_then(|a| a.try_borrow_mut().ok().map(|mut values| std::mem::take(&mut *values)));
|
||||
drop_held(held.unwrap_or_default().into_iter());
|
||||
}
|
||||
Made::Dict(d) => {
|
||||
let held = d.upgrade().and_then(|d| d.try_borrow_mut().ok().map(|mut d| std::mem::take(&mut *d)));
|
||||
drop_held(held.unwrap_or_default().into_values());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
ARRAYS_LEFT.with(|n| n.set(arrays.iter().filter(|a| a.strong_count() > 0).count()));
|
||||
}
|
||||
|
||||
impl std::ops::Deref for DictRef {
|
||||
type Target = Rc<RefCell<Dict>>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DictRef {
|
||||
fn drop(&mut self) {
|
||||
if Rc::strong_count(&self.0) == 1
|
||||
&& let Ok(mut d) = self.0.try_borrow_mut()
|
||||
{
|
||||
drop_held(d.drain().map(|(_, v)| v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// What the composite objects being dropped held, waiting for their turn (while any is).
|
||||
static HELD: RefCell<Option<Vec<Obj>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Drop what a composite object held, now that nothing else refers to it, without recursion: a
|
||||
/// program nests arrays and dictionaries as deep as it likes (`[ exch ]` in a loop), and dropping
|
||||
/// them one inside the other overflows the stack. What `held` holds in its turn waits in [`HELD`]
|
||||
/// for the outermost drop, which works through it.
|
||||
fn drop_held(held: impl Iterator<Item = Obj>) {
|
||||
let holds = |o: &Obj| matches!(o, Obj::Array { .. } | Obj::Dict(_) | Obj::File { .. } | Obj::GState(_));
|
||||
// (Without the list, when the thread is ending: dropped as they come.)
|
||||
let outermost = HELD.try_with(|h| {
|
||||
let Ok(mut h) = h.try_borrow_mut() else { return false };
|
||||
let outermost = h.is_none();
|
||||
h.get_or_insert_with(Vec::new).extend(held.filter(holds));
|
||||
outermost
|
||||
});
|
||||
if !matches!(outermost, Ok(true)) {
|
||||
return;
|
||||
}
|
||||
// Each drop here puts what it held on the list instead of dropping it.
|
||||
while let Ok(Some(next)) = HELD.try_with(|h| h.try_borrow_mut().ok().and_then(|mut h| h.as_mut().and_then(Vec::pop))) {
|
||||
drop(next);
|
||||
}
|
||||
let _ = HELD.try_with(|h| h.try_borrow_mut().map(|mut h| *h = None)); // Nothing to do when the thread is ending.
|
||||
}
|
||||
|
||||
/// What [`Shared`] values may be: bytes, or objects (which may hold more of themselves).
|
||||
pub(crate) trait Held: Sized {
|
||||
/// Drop the values of storage nothing refers to any more.
|
||||
fn release(_values: Vec<Self>) {}
|
||||
/// Storage of these values was made.
|
||||
fn made(_storage: &Rc<RefCell<Vec<Self>>>) {}
|
||||
}
|
||||
|
||||
impl Held for u8 {}
|
||||
|
||||
impl Held for Obj {
|
||||
fn release(values: Vec<Self>) {
|
||||
drop_held(values.into_iter());
|
||||
}
|
||||
fn made(storage: &Rc<RefCell<Vec<Self>>>) {
|
||||
note(Made::Array(Rc::downgrade(storage)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A dictionary key.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -72,7 +228,7 @@ impl Obj {
|
||||
}
|
||||
|
||||
pub fn dict(d: Dict) -> Self {
|
||||
Self::Dict(Rc::new(RefCell::new(d)))
|
||||
Self::Dict(DictRef::new(d))
|
||||
}
|
||||
|
||||
/// The number this is (integers and reals).
|
||||
@@ -135,15 +291,27 @@ impl Obj {
|
||||
/// The values of a string or an array: a run of storage that other strings or arrays may share
|
||||
/// (`getinterval` makes a new object over part of the same values, as PostScript does).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Shared<T> {
|
||||
pub(crate) struct Shared<T: Held> {
|
||||
buf: Rc<RefCell<Vec<T>>>,
|
||||
at: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<T: Clone> Shared<T> {
|
||||
impl<T: Held> Drop for Shared<T> {
|
||||
fn drop(&mut self) {
|
||||
if Rc::strong_count(&self.buf) == 1
|
||||
&& let Ok(mut values) = self.buf.try_borrow_mut()
|
||||
{
|
||||
T::release(std::mem::take(&mut *values));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + Held> Shared<T> {
|
||||
pub fn new(v: Vec<T>) -> Self {
|
||||
Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 }
|
||||
let (len, buf) = (v.len(), Rc::new(RefCell::new(v)));
|
||||
T::made(&buf);
|
||||
Self { len, buf, at: 0 }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -198,7 +366,7 @@ impl<T: Clone> Shared<T> {
|
||||
}
|
||||
|
||||
/// Hashes what [`Shared::same`] compares: the storage and the run of it.
|
||||
impl<T> Hash for Shared<T> {
|
||||
impl<T: Held> Hash for Shared<T> {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
Rc::as_ptr(&self.buf).hash(state);
|
||||
(self.at, self.len).hash(state);
|
||||
|
||||
@@ -19,6 +19,23 @@ pub(super) fn read(body: &str) -> crate::Imported {
|
||||
import(&eps(body)).unwrap()
|
||||
}
|
||||
|
||||
/// #1714: arrays and dictionaries a program links into themselves or each other are freed with
|
||||
/// the interpreter, also the ones it no longer reaches and when it stops on an error.
|
||||
#[test]
|
||||
fn arrays_and_dictionaries_holding_themselves_are_freed() {
|
||||
// The arrays still alive once a program's interpreter has released what it made: the ones
|
||||
// the interpreter itself holds, the same for every program here.
|
||||
let left = |program: &str| {
|
||||
let _ = import(&eps(program));
|
||||
crate::import::obj::ARRAYS_LEFT.with(std::cell::Cell::get)
|
||||
};
|
||||
let plain = left("/a 1 array def /b 1 array def a 0 b put");
|
||||
let cycles = "/a 1 array def a 0 a put /b 1 array def /c 1 array def b 0 c put c 0 b put /d 1 dict def /e 1 array def d /e e put e 0 d put 1 array dup 0 1 index put pop /a null def /b null def";
|
||||
for program in [cycles.to_string(), format!("{cycles} 1 0 div"), format!("{cycles} /p {{ p }} def")] {
|
||||
assert_eq!(left(&program), plain, "{program}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects of the document's layer.
|
||||
pub(super) fn objects(d: &Document) -> Vec<Arc<Node>> {
|
||||
d.layers[0].children().unwrap().clone()
|
||||
|
||||
@@ -389,7 +389,7 @@ impl Interp<'_> {
|
||||
d.insert(Key::name("FontMatrix"), matrix_obj(Affine::scale(0.001)));
|
||||
d.insert(Key::name("FontBBox"), Obj::array(vec![Obj::Int(0), Obj::Int(-250), Obj::Int(1000), Obj::Int(1000)]));
|
||||
d.insert(Key::name("Encoding"), Obj::array(vec![Obj::name(".notdef"); 256]));
|
||||
let font = Rc::new(std::cell::RefCell::new(d));
|
||||
let font = DictRef::new(d);
|
||||
self.fonts.borrow_mut().insert(key, Obj::Dict(font.clone()));
|
||||
Ok(font)
|
||||
}
|
||||
@@ -489,7 +489,7 @@ fn transformed(font: &DictRef, m: Affine) -> DictRef {
|
||||
let mut d = font.borrow().clone();
|
||||
let fm = d.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
d.insert(Key::name("FontMatrix"), matrix_obj(m * fm));
|
||||
Rc::new(std::cell::RefCell::new(d))
|
||||
DictRef::new(d)
|
||||
}
|
||||
|
||||
/// The character a glyph name stands for (`A`, `space`, `uni20AC`), else a bullet.
|
||||
|
||||
+16
-3
@@ -181,13 +181,26 @@ fn push85(s: &mut String, mut v: u32, n: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed.
|
||||
/// Most bytes [`ascii85_decode`] makes: what one filter or data section may hold.
|
||||
pub(crate) const MAX_ASCII85: usize = 1 << 28;
|
||||
|
||||
/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed or longer
|
||||
/// than [`MAX_ASCII85`].
|
||||
pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(text.len() * 4 / 5);
|
||||
ascii85_decode_within(text, MAX_ASCII85)
|
||||
}
|
||||
|
||||
/// [`ascii85_decode`] of at most `max` bytes: `z` is four bytes for one, so the length of the
|
||||
/// text doesn't bound what it becomes (#1715).
|
||||
pub(crate) fn ascii85_decode_within(text: &str, max: usize) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max));
|
||||
let mut group = [0u8; 5];
|
||||
let mut n = 0;
|
||||
let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable();
|
||||
while let Some(b) = chars.next() {
|
||||
if out.len() > max {
|
||||
return None;
|
||||
}
|
||||
match b {
|
||||
b'~' => {
|
||||
if chars.next() != Some(b'>') {
|
||||
@@ -201,7 +214,7 @@ pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
|
||||
let v = value85(&padded)?;
|
||||
out.extend(v.to_be_bytes().iter().take(n - 1));
|
||||
}
|
||||
return Some(out);
|
||||
return (out.len() <= max).then_some(out);
|
||||
}
|
||||
b'z' if n == 0 => out.extend([0; 4]),
|
||||
b'!'..=b'u' => {
|
||||
|
||||
@@ -542,7 +542,7 @@ impl<'a> Scene<'a> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
|
||||
let art = vectorcraft_brush::stroke_pieces(b, bp, st);
|
||||
let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st);
|
||||
self.note_item(st.opacity, st.blend);
|
||||
self.nest += 1;
|
||||
for piece in &art {
|
||||
|
||||
@@ -345,6 +345,11 @@ fn run_length_and_ascii85_round_trip() {
|
||||
assert_eq!(ps::ascii85(b"Man "), "9jqo^~>\n");
|
||||
assert_eq!(ps::ascii85(&[0, 0, 0, 0]), "z~>\n");
|
||||
assert!(ps::ascii85_decode("9jqo^").is_none(), "no end marker");
|
||||
// #1715: the result is bounded while it is made, not by the length of the text.
|
||||
assert_eq!(ps::ascii85_decode_within("zz~>", 8), Some(vec![0; 8]));
|
||||
assert!(ps::ascii85_decode_within("zzz~>", 8).is_none(), "three `z` are 12 bytes");
|
||||
assert!(ps::ascii85_decode_within("zz9jqo^~>", 8).is_none());
|
||||
assert!(ps::ascii85_decode_within(&format!("{}~>", "z".repeat(1 << 16)), 1 << 10).is_none());
|
||||
assert!(ps::ascii85_decode("9jqo^v~>").is_none(), "not base 85");
|
||||
assert_eq!(ps::num(1.0 / 3.0), "0.3333");
|
||||
assert_eq!((ps::num(-0.00001), ps::num(f64::NAN), ps::num(2.5)), ("0".into(), "0".into(), "2.5".into()));
|
||||
|
||||
+88
-11
@@ -4,7 +4,10 @@
|
||||
//!
|
||||
//! Where a folder refuses the temporary file or the rename (some network shares, a file another
|
||||
//! program holds open), the new content is first written and flushed elsewhere and only then copied
|
||||
//! into the file itself; if that copy fails, the temporary file stays and the error names it.
|
||||
//! into the file itself. That copy isn't atomic, so the file as it was is copied and flushed beside
|
||||
//! the temporary file first (#1695): if the copy into the file fails or is cut short, both versions
|
||||
//! are on disk, and the error names them. Where that copy of the old file can't be made, the file
|
||||
//! is left as it was and the save fails.
|
||||
//!
|
||||
//! On macOS `File::sync_all` is a full flush (`F_FULLFSYNC`), which SMB shares refuse with
|
||||
//! `ENOTSUP` while a plain `fsync` works: [`sync_file`] falls back to the plain one there (#1620).
|
||||
@@ -78,8 +81,10 @@ fn write_atomic_stepping(
|
||||
|
||||
/// The content of `tmp`, which is whole and flushed, copied into `target` itself, and `tmp`
|
||||
/// removed: for a folder that `refused` the atomic replacement. When `target` can't be opened for
|
||||
/// writing it is untouched: `tmp` is removed and `refused` returned. When the copy fails `target`
|
||||
/// is damaged: `tmp` stays and the error says where it is.
|
||||
/// writing it is untouched: `tmp` is removed and `refused` returned. When what it holds can't be
|
||||
/// copied aside first ([`keep_old`]) it is untouched too: `tmp` stays and the error names it. When
|
||||
/// the copy into `target` fails it is damaged: `tmp` and the copy of the old content stay and the
|
||||
/// error says where they are.
|
||||
fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
|
||||
let opened = File::open(tmp).and_then(|from| Ok((from, OpenOptions::new().write(true).create(true).truncate(false).open(target)?)));
|
||||
let Ok((mut from, mut to)) = opened else {
|
||||
@@ -87,18 +92,42 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
return Err(refused);
|
||||
};
|
||||
// Nothing to keep of a file that is new or empty.
|
||||
let old = to.metadata().map_or(true, |m| m.len() > 0).then(|| tmp.with_extension("old"));
|
||||
if let Some(old) = &old
|
||||
&& let Err(e) = keep_old(target, old)
|
||||
{
|
||||
// Best effort: a partial copy is of no use.
|
||||
let _ = std::fs::remove_file(old);
|
||||
let why =
|
||||
format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display());
|
||||
return Err(io::Error::new(refused.kind(), why));
|
||||
}
|
||||
let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to));
|
||||
drop((from, to));
|
||||
match copied {
|
||||
Ok(()) => {
|
||||
// Best effort: the file is saved, so the copy is no longer needed.
|
||||
match (copied, old) {
|
||||
(Ok(()), old) => {
|
||||
// Best effort: the file is saved, so the copies are no longer needed.
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
if let Some(old) = old {
|
||||
let _ = std::fs::remove_file(old);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
|
||||
(Err(e), Some(old)) => {
|
||||
Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())))
|
||||
}
|
||||
(Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A flushed copy of `target` as it is, at `old` (for its owner only).
|
||||
fn keep_old(target: &Path, old: &Path) -> io::Result<()> {
|
||||
let mut to = create_private(old)?;
|
||||
io::copy(&mut File::open(target)?, &mut to)?;
|
||||
sync_file(&to)
|
||||
}
|
||||
|
||||
/// A new file for its owner only (Unix): the system's temporary folder is shared.
|
||||
fn create_private(path: &Path) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
@@ -185,8 +214,15 @@ fn plain_fsync(file: &File) -> io::Result<()> {
|
||||
|
||||
fn full_sync(file: &File) -> io::Result<()> {
|
||||
#[cfg(test)]
|
||||
if REFUSE_FULL_SYNC.with(std::cell::Cell::get) {
|
||||
return Err(io::Error::from_raw_os_error(ENOTSUP));
|
||||
{
|
||||
if REFUSE_FULL_SYNC.with(std::cell::Cell::get) {
|
||||
return Err(io::Error::from_raw_os_error(ENOTSUP));
|
||||
}
|
||||
match FLUSHES_LEFT.with(std::cell::Cell::get) {
|
||||
Some(0) => return Err(io::Error::other("the connection was lost")),
|
||||
Some(n) => FLUSHES_LEFT.with(|c| c.set(Some(n - 1))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
file.sync_all()
|
||||
}
|
||||
@@ -203,6 +239,8 @@ thread_local! {
|
||||
static REFUSE_FULL_SYNC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Tests: the plain `fsync` fallback fails too, with this OS error.
|
||||
static PLAIN_FSYNC_FAILS: std::cell::Cell<Option<i32>> = const { std::cell::Cell::new(None) };
|
||||
/// Tests: after this many more flushes on this thread, every flush fails.
|
||||
static FLUSHES_LEFT: std::cell::Cell<Option<u32>> = const { std::cell::Cell::new(None) };
|
||||
/// Tests: how many plain `fsync` fallbacks were made on this thread.
|
||||
static PLAIN_FSYNCS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
@@ -363,8 +401,46 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A refused rename and a copy into the file that fails (here: its flush): the file is damaged,
|
||||
/// so the temporary file with the whole new content stays and the error names it.
|
||||
/// #1695: a refused rename and a copy into the file that fails (here: its flush, after the copy
|
||||
/// of the old content was flushed): the file is damaged, so the temporary file with the whole
|
||||
/// new content and the copy of the old one stay, and the error names both.
|
||||
#[test]
|
||||
fn a_failed_copy_into_the_file_keeps_the_old_content_and_the_new() {
|
||||
let d = dir("no-rename-copy-fails-late");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let r = write_atomic_stepping(
|
||||
&path,
|
||||
|f| f.write_all(b"new"),
|
||||
create_new,
|
||||
|_, _| {
|
||||
// One more flush works (the copy of the old content), the next doesn't.
|
||||
FLUSHES_LEFT.with(|c| c.set(Some(1)));
|
||||
denied()
|
||||
},
|
||||
);
|
||||
FLUSHES_LEFT.with(|c| c.set(None));
|
||||
let message = r.unwrap_err().to_string();
|
||||
let mut left = leftovers(&d, "doc.vectorcraft");
|
||||
left.sort();
|
||||
let [old, tmp] = left.as_slice() else { panic!("the old content and the new: {left:?}") };
|
||||
assert!(old.ends_with(".old") && tmp.ends_with(".tmp"), "{left:?}");
|
||||
assert!(message.contains(tmp.as_str()) && message.contains(old.as_str()), "{message}");
|
||||
assert_eq!((std::fs::read(d.join(old)).unwrap(), std::fs::read(d.join(tmp)).unwrap()), (b"original".to_vec(), b"new".to_vec()));
|
||||
// A new file has nothing to keep, and a save that works leaves neither copy.
|
||||
std::fs::remove_file(d.join(old)).unwrap();
|
||||
std::fs::remove_file(d.join(tmp)).unwrap();
|
||||
let fresh = d.join("fresh.vectorcraft");
|
||||
write_atomic_stepping(&fresh, |f| f.write_all(b"new"), create_new, |_, _| denied()).unwrap();
|
||||
write_atomic_stepping(&fresh, |f| f.write_all(b"newer"), create_new, |_, _| denied()).unwrap();
|
||||
assert_eq!(std::fs::read(&fresh).unwrap(), b"newer");
|
||||
assert_eq!(leftovers(&d, "doc.vectorcraft"), ["fresh.vectorcraft"]);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A refused rename where the old content can't be copied aside (here: no flush works): the
|
||||
/// file is left as it was, the temporary file with the whole new content stays and the error
|
||||
/// names it.
|
||||
#[test]
|
||||
fn a_failed_copy_into_the_file_keeps_the_temporary_file() {
|
||||
let d = dir("no-rename-copy-fails");
|
||||
@@ -388,6 +464,7 @@ mod tests {
|
||||
let [tmp] = left.as_slice() else { panic!("one temporary file: {left:?}") };
|
||||
assert!(message.contains(tmp.as_str()), "{message}");
|
||||
assert_eq!(std::fs::read(d.join(tmp)).unwrap(), b"new");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original", "not overwritten without a copy of it");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
|
||||
@@ -482,6 +482,9 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
#[derive(Debug)]
|
||||
pub struct NativeFile {
|
||||
pub doc: Document,
|
||||
/// What was done to bring the document within the nesting caps
|
||||
/// ([`Document::limit_depth_noting`]: a file saved before them, or a damaged one).
|
||||
pub depth_notes: Vec<String>,
|
||||
pub info: FileInfo,
|
||||
/// ICC files by profile name (saved with Embed ICC Profiles; undecodable ones are left out).
|
||||
pub profiles: BTreeMap<String, Vec<u8>>,
|
||||
@@ -511,7 +514,11 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
|
||||
/// [`load`], with everything else the file carries.
|
||||
pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
|
||||
let file = unpack::Unpacked::new(bytes)?;
|
||||
let f: File = serde_json::from_slice(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
|
||||
// The JSON reader stops at 128 levels (a document's objects use three each).
|
||||
let f: File = serde_json::from_slice(file.json()).map_err(|e| {
|
||||
let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() };
|
||||
FormatError::NotVectorcraft(why)
|
||||
})?;
|
||||
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
|
||||
return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format)));
|
||||
}
|
||||
@@ -545,8 +552,10 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
|
||||
// Untrusted: capped, named and pointing at objects the file has.
|
||||
doc.tidy_saved_selections();
|
||||
doc.tidy_graph_designs();
|
||||
let mut depth_notes = vec![];
|
||||
doc.limit_depth_noting(&mut depth_notes);
|
||||
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
|
||||
Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
|
||||
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
|
||||
}
|
||||
|
||||
/// The members of a native file besides its document (which is skipped, not decoded).
|
||||
|
||||
@@ -66,7 +66,8 @@ impl<W: Write> Write for Out<W> {
|
||||
const OUT_CHUNK: usize = 64 << 10;
|
||||
|
||||
/// A gzip stream, written as [`flate2::write::GzEncoder`] at the default level writes it (the
|
||||
/// same header, and the same bytes for a file without compressed images), except that data which
|
||||
/// same header and the same data; the deflate blocks are the same too where flate2 deflates with
|
||||
/// miniz_oxide, not with zlib-rs, which writes them by how the data is fed), except that data which
|
||||
/// is compressed already ([`Gz::packed`]) is stored as it is: the default level spends a second
|
||||
/// or more per photo on it for next to nothing, and makes it slower to unpack. Where the level
|
||||
/// changes the deflate blocks so far end with a sync flush and a fresh compressor writes on,
|
||||
|
||||
@@ -122,6 +122,11 @@ impl<'a> Unpacked<'a> {
|
||||
/// data (entries sharing bytes, only in hand-made files, would copy them once each). Entries
|
||||
/// are read in the order of their offsets, each straight into its own buffer.
|
||||
pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result<Vec<Option<Vec<u8>>>, FormatError> {
|
||||
self.entries_within(wanted, MAX_WORK)
|
||||
}
|
||||
|
||||
/// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them.
|
||||
fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result<Vec<Option<Vec<u8>>>, FormatError> {
|
||||
let mut out: Vec<Option<Vec<u8>>> = vec![None; wanted.len()];
|
||||
let in_memory = |tail: &[u8], out: &mut Vec<Option<Vec<u8>>>| {
|
||||
for (slot, &[offset, len]) in out.iter_mut().zip(wanted) {
|
||||
@@ -147,7 +152,7 @@ impl<'a> Unpacked<'a> {
|
||||
drop(self.json);
|
||||
let mut order: Vec<usize> = (0..wanted.len()).collect();
|
||||
order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0]));
|
||||
let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at };
|
||||
let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at, left: work };
|
||||
for i in order {
|
||||
let Some(&[offset, len]) = wanted.get(i) else { continue };
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
@@ -155,6 +160,7 @@ impl<'a> Unpacked<'a> {
|
||||
}
|
||||
}
|
||||
// To the end, so the stream's checksum is verified as before.
|
||||
s.left = MAX_BLOBS;
|
||||
s.skip(MAX_BLOBS)?;
|
||||
}
|
||||
}
|
||||
@@ -188,12 +194,29 @@ struct Stream<'a> {
|
||||
file: &'a [u8],
|
||||
/// Where the data after the JSON starts in the unpacked stream.
|
||||
at: u64,
|
||||
/// Bytes that may still be unpacked ([`MAX_WORK`] in all): the entries' own, the gaps before
|
||||
/// them, and the stream from its start again for each entry behind the one before.
|
||||
left: u64,
|
||||
}
|
||||
|
||||
/// Most bytes unpacked to read a streamed file's entries: the entries and the gaps between them
|
||||
/// ([`MAX_BLOBS`] in a file a writer made, where they follow each other), and the JSON once more.
|
||||
/// The entries' lengths alone don't bound it: one byte asked for at a far offset unpacks all
|
||||
/// that lies before it, and each entry behind the last one starts the stream over (#1699).
|
||||
const MAX_WORK: u64 = MAX_BLOBS + MAX_JSON;
|
||||
|
||||
impl Stream<'_> {
|
||||
/// Count `n` bytes unpacked against the budget.
|
||||
fn spend(&mut self, n: u64) -> Result<(), FormatError> {
|
||||
self.left = self.left.checked_sub(n).ok_or(FormatError::TooBig)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unpack and drop up to `n` bytes → how many there were.
|
||||
fn skip(&mut self, n: u64) -> Result<u64, FormatError> {
|
||||
let skipped = io::copy(&mut (&mut self.r).take(n), &mut io::sink()).map_err(damaged)?;
|
||||
// One byte past the budget at most, so going over it is seen without doing the work.
|
||||
let skipped = io::copy(&mut (&mut self.r).take(n.min(self.left.saturating_add(1))), &mut io::sink()).map_err(damaged)?;
|
||||
self.spend(skipped)?;
|
||||
self.pos += skipped;
|
||||
Ok(skipped)
|
||||
}
|
||||
@@ -204,7 +227,8 @@ impl Stream<'_> {
|
||||
// Entries out of order or overlapping (only hand-made files): unpack again from the
|
||||
// start.
|
||||
let mut dec = GzDecoder::new(self.file);
|
||||
let skipped = io::copy(&mut (&mut dec).take(self.at), &mut io::sink()).map_err(damaged)?;
|
||||
let skipped = io::copy(&mut (&mut dec).take(self.at.min(self.left.saturating_add(1))), &mut io::sink()).map_err(damaged)?;
|
||||
self.spend(skipped)?;
|
||||
if skipped < self.at {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -219,7 +243,8 @@ impl Stream<'_> {
|
||||
let n = usize::try_from(len).map_err(|_| FormatError::TooBig)?;
|
||||
// The length is the file's word: reserve up to a bound, and let real data grow the rest.
|
||||
bytes.try_reserve_exact(n.min(RESERVE_MAX)).map_err(|_| FormatError::TooBig)?;
|
||||
let got = (&mut self.r).take(len).read_to_end(&mut bytes).map_err(damaged)?;
|
||||
let got = (&mut self.r).take(len.min(self.left.saturating_add(1))).read_to_end(&mut bytes).map_err(damaged)?;
|
||||
self.spend(got as u64)?;
|
||||
self.pos += got as u64;
|
||||
Ok((got as u64 == len).then_some(bytes))
|
||||
}
|
||||
@@ -237,6 +262,35 @@ mod tests {
|
||||
gz.finish().unwrap()
|
||||
}
|
||||
|
||||
/// #1699: reading a streamed file's entries unpacks a bounded number of bytes, whatever
|
||||
/// their offsets: a far offset and entries behind one another are refused past the budget.
|
||||
#[test]
|
||||
fn a_streamed_files_entries_are_found_within_a_work_budget() {
|
||||
let tail: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = b"{}".to_vec();
|
||||
file.extend(crate::BLOB_MAGIC);
|
||||
file.extend(&tail);
|
||||
let file = gzip(&file);
|
||||
let read = |wanted: &[[u64; 2]], work: u64| {
|
||||
let u = Unpacked::new(&file).unwrap();
|
||||
assert!(matches!(u.tail, Tail::Stream { .. }), "read as a stream");
|
||||
u.entries_within(wanted, work)
|
||||
};
|
||||
// Entries one after the other: the tail is unpacked once.
|
||||
let parts = read(&[[0, 1000], [1000, 3096]], 4096).unwrap();
|
||||
assert_eq!(parts, vec![Some(tail[..1000].to_vec()), Some(tail[1000..].to_vec())]);
|
||||
// One byte at the far end is the whole tail's work.
|
||||
assert_eq!(read(&[[4095, 1]], 4096).unwrap(), vec![Some(vec![tail[4095]])]);
|
||||
assert!(matches!(read(&[[4095, 1]], 4000), Err(FormatError::TooBig)));
|
||||
// Entries over the same bytes start the stream again each time: counted, then refused.
|
||||
let same = vec![[4000, 96]; 50];
|
||||
assert!(read(&same, 50 * (4096 + 64)).is_ok());
|
||||
assert!(matches!(read(&same, 3 * 4096), Err(FormatError::TooBig)));
|
||||
// An offset past the data is still just missing, and the default budget reads all of it.
|
||||
assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]);
|
||||
assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(tail.clone())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_sharing_bytes_obey_the_limits_in_memory_too() {
|
||||
// Each entry within MAX_BLOB, together past MAX_BLOBS: many references to the same bytes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Saving and opening as streams: the bytes a save writes are those an in-memory save wrote
|
||||
//! before (the same JSON and data after it, unpacked; the same gzip bytes when no image is
|
||||
//! compressed already), files written the old way still open, entries are read whatever their
|
||||
//! order, and the limits apply per part of the file.
|
||||
//! before (the same JSON and data after it, unpacked; the same gzip header), files written the old
|
||||
//! way still open, entries are read whatever their order, and the limits apply per part of the
|
||||
//! file.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
@@ -67,13 +67,17 @@ fn compressed_files_unpack_to_the_plain_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The deflate bytes themselves depend on flate2's backend: zlib-rs (which hayro turns on) writes
|
||||
/// other blocks for data fed in other pieces than the in-memory save fed it, miniz_oxide didn't.
|
||||
#[test]
|
||||
fn without_compressed_images_the_gzip_bytes_are_the_old_ones() {
|
||||
fn without_compressed_images_the_gzip_stream_is_the_old_one() {
|
||||
let mut d = doc_with(&[("scan", "image/tiff", INLINE_MAX * 2), ("icon", "image/png", 100)]);
|
||||
d.title = "No photos".into();
|
||||
for o in [SaveOptions::default(), SaveOptions { pdf: Some(vec![b'%'; INLINE_MAX]), ..SaveOptions::default() }] {
|
||||
let plain = save_with(&d, &o).unwrap();
|
||||
assert_eq!(save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain));
|
||||
let (packed, old) = (save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain));
|
||||
assert_eq!(packed.get(..10), old.get(..10), "the same header");
|
||||
assert_eq!(gunzip(&packed), plain);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,9 @@ pub struct Imported {
|
||||
/// Read an EMF or WMF file (with or without a placeable header) into a document whose one
|
||||
/// artboard is the picture's frame.
|
||||
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
import::import(bytes)
|
||||
let mut i = import::import(bytes)?;
|
||||
i.document.limit_depth_noting(&mut i.warnings);
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -403,7 +403,7 @@ impl<'a> Scene<'a> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
|
||||
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
|
||||
Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st))
|
||||
}
|
||||
|
||||
/// A placed image, decoded once per image.
|
||||
|
||||
@@ -20,7 +20,7 @@ pub use edit::{
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder};
|
||||
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, has_open_path, interior_point, live_paint, shape_builder};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
|
||||
@@ -102,7 +102,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
|
||||
}
|
||||
// Open paths are cutting edges for Divide; the filled-only operations keep their
|
||||
// implicit closure. Reuse Shape Builder's bounded planar map and its fallback.
|
||||
if op == PathfinderOp::Divide && shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) {
|
||||
if op == PathfinderOp::Divide && crate::has_open_path(shapes) {
|
||||
return crate::shape_builder(shapes, false)
|
||||
.regions
|
||||
.into_iter()
|
||||
@@ -298,21 +298,22 @@ pub fn merge_regions(regions: &[&Region]) -> PathData {
|
||||
/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where
|
||||
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as
|
||||
/// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way
|
||||
/// (#893). Either falls back to [`merge_regions`] when it can't place a face.
|
||||
/// (#893), and so do faces with a gap closed paths enclose, which no shape covers (#1691). Either
|
||||
/// falls back to [`merge_regions`] when it can't place a face.
|
||||
pub struct FaceMerger {
|
||||
/// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes.
|
||||
arr: Option<Arrangement>,
|
||||
shapes: usize,
|
||||
/// With open paths, the shapes, and their planar map once a merge has needed it.
|
||||
/// The shapes, and their planar map once a merge has needed it (open paths, or a gap).
|
||||
lines: Option<(Vec<Shape>, std::cell::OnceCell<Option<crate::planar::Map>>)>,
|
||||
}
|
||||
|
||||
impl FaceMerger {
|
||||
pub fn new(shapes: &[Shape]) -> Self {
|
||||
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
|
||||
let has_open = crate::has_open_path(shapes);
|
||||
// The Shape Builder's faces come from the planar map only up to its cap.
|
||||
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
|
||||
let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
|
||||
let lines = (segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
|
||||
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines }
|
||||
}
|
||||
|
||||
@@ -321,8 +322,8 @@ impl FaceMerger {
|
||||
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
|
||||
}
|
||||
|
||||
/// `faces` merged in the planar map of shapes with open paths: each face found again by a
|
||||
/// point inside it, and the result checked to cover what they cover.
|
||||
/// `faces` merged in the planar map of the shapes: each face found again by a point inside
|
||||
/// it, and the result checked to cover what they cover.
|
||||
fn planar(&self, faces: &[&Region]) -> Option<PathData> {
|
||||
let (shapes, map) = self.lines.as_ref()?;
|
||||
let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?;
|
||||
@@ -335,6 +336,10 @@ impl FaceMerger {
|
||||
}
|
||||
|
||||
fn exact(&self, faces: &[&Region]) -> Option<PathData> {
|
||||
// A gap no shape covers is no face of the arrangement: it merges in the planar map.
|
||||
if faces.iter().any(|r| r.sources.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let arr = self.arr.as_ref()?;
|
||||
// Each face by its coverage and a point inside it.
|
||||
let mut picks: Vec<(Vec<bool>, Point)> = Vec::with_capacity(faces.len());
|
||||
|
||||
@@ -307,8 +307,9 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
build(shapes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Most segments the Shape Builder sweeps with open paths as edges: the planar map costs far more
|
||||
/// than the filled areas' arrangement, so past this only the filled areas count.
|
||||
/// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed
|
||||
/// paths enclose, outline edges): the map costs far more than the filled areas' arrangement, so past
|
||||
/// this only the filled areas count.
|
||||
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
|
||||
|
||||
/// The Shape Builder's arrangement: see [`shape_builder`].
|
||||
@@ -322,20 +323,26 @@ pub struct BuilderArrangement {
|
||||
pub edges: Vec<Shape>,
|
||||
}
|
||||
|
||||
/// Does any of `shapes` have an open subpath (a cutting edge for the Shape Builder)?
|
||||
pub fn has_open_path(shapes: &[Shape]) -> bool {
|
||||
shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2)
|
||||
}
|
||||
|
||||
/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with
|
||||
/// `edges`, the pieces of their outlines (what erasing an edge deletes).
|
||||
///
|
||||
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
|
||||
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
|
||||
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
|
||||
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
|
||||
/// its index in `shapes`. Outlines are cut the same way. With no open path, more than
|
||||
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions), and the
|
||||
/// gaps they enclose that nothing fills are regions too (empty `sources`), such as the middle of
|
||||
/// four circles that touch (#1691). Open paths (the caller closes the ones whose fill should count)
|
||||
/// are cutting edges: a line across a shape splits it, lines around an area enclose a region
|
||||
/// nothing fills, and each path with no closed subpath comes back cut wherever another path meets
|
||||
/// it, as pieces keyed by its index in `shapes`. Outlines are cut the same way. With more than
|
||||
/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas
|
||||
/// count and there are no pieces.
|
||||
pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
|
||||
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
|
||||
let has_open = has_open_path(shapes);
|
||||
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
|
||||
let planar = ((has_open || edges) && segments <= SHAPE_BUILDER_MAX_SEGMENTS)
|
||||
let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS)
|
||||
.then(|| {
|
||||
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
|
||||
live_paint(&keyed)
|
||||
@@ -346,11 +353,15 @@ pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
|
||||
};
|
||||
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
|
||||
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
|
||||
BuilderArrangement {
|
||||
regions: if has_open { faces } else { crate::regions(shapes) },
|
||||
lines: if has_open { lines } else { Vec::new() },
|
||||
edges: if edges { outline } else { Vec::new() },
|
||||
}
|
||||
let regions = if has_open {
|
||||
faces
|
||||
} else {
|
||||
// The filled areas from their arrangement, whose faces merge exactly, then the gaps.
|
||||
let mut regions = crate::regions(shapes);
|
||||
regions.extend(faces.into_iter().filter(|f| f.sources.is_empty()));
|
||||
regions
|
||||
};
|
||||
BuilderArrangement { regions, lines: if has_open { lines } else { Vec::new() }, edges: if edges { outline } else { Vec::new() } }
|
||||
}
|
||||
|
||||
/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the
|
||||
@@ -1182,6 +1193,43 @@ mod tests {
|
||||
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
fn circle(cx: f64, cy: f64, r: f64, key: u64) -> Shape {
|
||||
Shape::new(shapes::ellipse(Rect::new(cx - r, cy - r, cx + r, cy + r)), FillRule::NonZero, key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_offers_the_gaps_closed_shapes_enclose() {
|
||||
// Four circles in a 2×2 grid, each touching its neighbours (#1691): the four circles and
|
||||
// the diamond between them, which nothing fills.
|
||||
let s = [circle(50.0, 50.0, 50.0, 0), circle(150.0, 50.0, 50.0, 1), circle(50.0, 150.0, 50.0, 2), circle(150.0, 150.0, 50.0, 3)];
|
||||
let regions = shape_builder(&s, false).regions;
|
||||
assert_eq!(regions.len(), 5, "{regions:?}");
|
||||
let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect();
|
||||
assert_eq!(gaps.len(), 1);
|
||||
assert!(gaps[0].contains(Point::new(100.0, 100.0)) && !gaps[0].contains(Point::new(50.0, 50.0)));
|
||||
let diamond = 10000.0 - std::f64::consts::PI * 2500.0;
|
||||
assert!((area(gaps[0]).abs() - diamond).abs() < 0.01 * diamond, "{}", area(gaps[0]));
|
||||
assert_eq!(shape_builder(&s, true).regions, regions, "the outline edges don't change them");
|
||||
// A ring of four bars that overlap at the corners: the eight pieces of the bars and the
|
||||
// square they enclose.
|
||||
let s =
|
||||
[rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)];
|
||||
let regions = shape_builder(&s, false).regions;
|
||||
assert_eq!(regions.len(), 9, "{regions:?}");
|
||||
let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect();
|
||||
assert_eq!(gaps.len(), 1);
|
||||
assert!((area(gaps[0]).abs() - 10000.0).abs() < 1e-6 && gaps[0].contains(Point::new(150.0, 150.0)));
|
||||
// The gap merges with a bar in the planar map: the bar's piece and the square, no seam.
|
||||
let merger = crate::FaceMerger::new(&s);
|
||||
let bar = regions.iter().find(|r| r.sources == [0] && r.contains(Point::new(150.0, 50.0))).unwrap();
|
||||
let merged = merger.merge(&[bar, gaps[0]]);
|
||||
assert_eq!(merged.subpaths.len(), 1, "{merged:?}");
|
||||
assert!((crate::area(&merged, FillRule::NonZero) - 20000.0).abs() < 1e-6);
|
||||
// Shapes apart or overlapping without a hole enclose no gap.
|
||||
let s = [circle(50.0, 50.0, 50.0, 0), circle(250.0, 50.0, 50.0, 1), rect(80.0, 0.0, 220.0, 60.0, 2)];
|
||||
assert!(shape_builder(&s, false).regions.iter().all(|r| !r.sources.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_stays_quick_up_to_its_cap() {
|
||||
// A wavy ring of nearly the most segments the map takes, crossed by a few lines.
|
||||
@@ -1194,6 +1242,10 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)];
|
||||
// Closed paths alone are swept for the gaps they enclose.
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap");
|
||||
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
|
||||
for k in 0..4 {
|
||||
let y = -60.0 + 40.0 * f64::from(k);
|
||||
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
|
||||
|
||||
@@ -23,10 +23,11 @@ image = { workspace = true }
|
||||
# CMYK JPEGs (the image crate writes RGB and grey ones only).
|
||||
jpeg-encoder = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
hayro-syntax = "0.7.2"
|
||||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
|
||||
hayro-syntax = "0.8.0"
|
||||
hayro-interpret = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
|
||||
log = { workspace = true }
|
||||
smallvec = "1"
|
||||
weezl = "0.1"
|
||||
serde = { workspace = true }
|
||||
# PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm.
|
||||
aes = "0.8"
|
||||
@@ -38,3 +39,5 @@ vectorcraft-testkit = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
serde_json = { workspace = true }
|
||||
# `draw_text`, to write test files with live text as other writers do.
|
||||
krilla = { workspace = true, features = ["simple-text"] }
|
||||
@@ -1336,7 +1336,7 @@ impl Exporter<'_> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
|
||||
let art = vectorcraft_brush::stroke_pieces(b, bp, st);
|
||||
let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st);
|
||||
let mut pushes = 0;
|
||||
if st.blend != BlendMode::Normal {
|
||||
s.push_blend_mode(blend(st.blend));
|
||||
|
||||
+113
-64
@@ -3,12 +3,12 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::font::{Glyph, GlyphRun as ShownGlyphs};
|
||||
use hayro_interpret::hayro_cmap::BfString;
|
||||
use hayro_interpret::pattern::{Pattern, TilingPattern};
|
||||
use hayro_interpret::{
|
||||
CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData,
|
||||
MaskType, PathDrawMode, SoftMask, StrokeProps, interpret_page,
|
||||
CacheKey, ClipPath, Context, Device, DrawMode, DrawProps, Image, ImageData, ImageDrawProps, InterpreterCache, InterpreterSettings,
|
||||
InterpreterWarning, LumaData, MaskType, SoftMask, StrokeProps, interpret_page,
|
||||
};
|
||||
use hayro_syntax::object::Name;
|
||||
use kurbo::{Affine, BezPath, PathEl, Rect, Shape};
|
||||
@@ -147,6 +147,13 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it,
|
||||
|
||||
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
|
||||
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
|
||||
// The interpreter draws forms, patterns, glyphs and masks inside one another by recursion, in
|
||||
// large stack frames: it caps that (50 deep), and gets room for the cap whatever thread asks.
|
||||
vectorcraft_doc::depth::with_deep_stack(|| read(bytes, opts))
|
||||
}
|
||||
|
||||
/// [`import_with_report`] on the thread that asks.
|
||||
pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
|
||||
let original = crate::pages::open(bytes, opts.password.as_deref())?;
|
||||
let picked = crate::pages::picked(opts, original.pages().len())?;
|
||||
let mut ocgs = Ocgs::read(&original);
|
||||
@@ -174,6 +181,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let msg = match w {
|
||||
InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped",
|
||||
InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped",
|
||||
// Annotations aren't drawn (`render_annotations` below), and a kind of warning
|
||||
// added later has no message yet.
|
||||
_ => return,
|
||||
};
|
||||
if let Ok(mut v) = sink2.lock()
|
||||
&& !v.iter().any(|x| x == msg)
|
||||
@@ -417,6 +427,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.limit_depth_noting(&mut warnings);
|
||||
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text })
|
||||
}
|
||||
|
||||
@@ -1259,8 +1270,8 @@ impl<'p> Builder<'p> {
|
||||
/// before it on the open line was stroked so too, is held with the line instead of ending it
|
||||
/// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the
|
||||
/// type). `false`: it isn't one.
|
||||
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool {
|
||||
let PathDrawMode::Stroke(props) = draw_mode else { return false };
|
||||
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &DrawMode) -> bool {
|
||||
let DrawMode::Stroke(props) = draw_mode else { return false };
|
||||
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) {
|
||||
return false;
|
||||
}
|
||||
@@ -1352,7 +1363,11 @@ impl<'p> Builder<'p> {
|
||||
Some(art) => {
|
||||
let [r, g, b, _] = m.background_color().to_rgba().components();
|
||||
let backdrop = luminance(&Color::rgb(r, g, b));
|
||||
let inverted = m.transfer_function().is_some_and(|tf| tf.apply(0.0) > tf.apply(1.0));
|
||||
let inverted = m.transfer_function().is_some_and(|tf| {
|
||||
let mut ends = [0, 255];
|
||||
tf.apply_to(&mut ends);
|
||||
ends[0] > ends[1]
|
||||
});
|
||||
let id = self.id();
|
||||
mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all))
|
||||
}
|
||||
@@ -1681,7 +1696,14 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Device<'a> for Builder<'_> {
|
||||
/// What the interpreter draws, as the device below gets it.
|
||||
impl<'a> Builder<'_> {
|
||||
/// The soft mask and blend mode of what is drawn next.
|
||||
fn set_state(&mut self, mask: Option<SoftMask<'a>>, blend_mode: hayro_interpret::BlendMode) {
|
||||
self.set_soft_mask(mask);
|
||||
self.set_blend_mode(blend_mode);
|
||||
}
|
||||
|
||||
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
|
||||
let spec = mask.map(|m| self.soft_mask(&m));
|
||||
let same = match (&spec, &self.mask) {
|
||||
@@ -1704,7 +1726,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
|
||||
fn path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &DrawMode) {
|
||||
self.flush_glyphs();
|
||||
if self.hold_glyph_stroke(path, transform, paint, draw_mode) {
|
||||
self.pending = false;
|
||||
@@ -1726,19 +1748,19 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
hayro_interpret::Paint::Color(_) => None,
|
||||
};
|
||||
// Patch and triangle meshes are gradient meshes.
|
||||
if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
|
||||
if let (DrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
|
||||
&& let Some(meshes) = mesh_shading(sp, &mut self.colors)
|
||||
{
|
||||
self.draw_meshes(meshes, &bp, sp.opacity);
|
||||
return;
|
||||
}
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
let stroke = matches!(draw_mode, PathDrawMode::Stroke(_));
|
||||
let stroke = matches!(draw_mode, DrawMode::Stroke(_));
|
||||
let (paint, opacity) = self.paint(paint, stroke);
|
||||
// A gradient that doesn't extend past its ends paints only between them.
|
||||
let width = match draw_mode {
|
||||
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
|
||||
PathDrawMode::Fill(_) => 0.0,
|
||||
DrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
|
||||
_ => 0.0,
|
||||
};
|
||||
let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width)));
|
||||
let wrap = |b: &mut Self, n: Node| match &band {
|
||||
@@ -1750,7 +1772,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
None => n,
|
||||
};
|
||||
match draw_mode {
|
||||
PathDrawMode::Fill(rule) => {
|
||||
DrawMode::Fill(rule) => {
|
||||
let mut f = FillLayer::new(paint);
|
||||
(f.opacity, f.overprint) = (opacity, overprint);
|
||||
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
|
||||
@@ -1761,7 +1783,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let n = wrap(self, n);
|
||||
self.push_node(n);
|
||||
}
|
||||
PathDrawMode::Stroke(props) => {
|
||||
DrawMode::Stroke(props) => {
|
||||
let mut st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
||||
st.overprint = overprint;
|
||||
// Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke
|
||||
@@ -1796,6 +1818,71 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let n = wrap(self, n);
|
||||
self.push_node(n);
|
||||
}
|
||||
// (`draw_path` gives a fill and a stroke one after the other, and no path is invisible.)
|
||||
DrawMode::FillAndStroke(..) | DrawMode::Invisible => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph: filled, or stroked with `stroke`.
|
||||
fn glyph(
|
||||
&mut self,
|
||||
glyph: &Glyph<'a>,
|
||||
transform: Affine,
|
||||
glyph_transform: Affine,
|
||||
paint: &hayro_interpret::Paint<'a>,
|
||||
stroke: Option<&StrokeProps>,
|
||||
) {
|
||||
self.pending = false;
|
||||
match glyph {
|
||||
Glyph::Outline(o) => {
|
||||
let scale = mean_scale(transform);
|
||||
let overprint = self.glyph_overprint(stroke.is_some());
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke, overprint) {
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
let mut bp = o.outline();
|
||||
bp.apply_affine(transform * glyph_transform);
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let same = self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == paint
|
||||
&& r.opacity == opacity
|
||||
&& r.overprint == overprint
|
||||
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.map(|s| s.line_width)
|
||||
&& (r.scale - scale).abs() < 1e-9
|
||||
});
|
||||
if !same {
|
||||
self.flush_glyphs();
|
||||
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke: stroke.cloned(), scale, overprint });
|
||||
}
|
||||
if let Some(r) = &mut self.glyphs {
|
||||
r.path.extend(bp.iter());
|
||||
}
|
||||
self.warn("text was converted to outlines");
|
||||
}
|
||||
Glyph::Type3(t3) => {
|
||||
if self.nested >= MAX_NESTED {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
self.nested += 1;
|
||||
t3.interpret(self, transform, glyph_transform, paint);
|
||||
self.nested -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Device<'a> for Builder<'_> {
|
||||
fn draw_path(&mut self, path: &BezPath, props: DrawProps<'a>, draw_mode: &DrawMode) {
|
||||
self.set_state(props.soft_mask, props.blend_mode);
|
||||
match draw_mode {
|
||||
DrawMode::Invisible => {}
|
||||
DrawMode::FillAndStroke(rule, stroke) => {
|
||||
self.path(path, props.transform, &props.paint, &DrawMode::Fill(*rule));
|
||||
self.path(path, props.transform, &props.paint, &DrawMode::Stroke(stroke.clone()));
|
||||
}
|
||||
one => self.path(path, props.transform, &props.paint, one),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1831,60 +1918,22 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.blend = BlendMode::Normal;
|
||||
}
|
||||
|
||||
fn draw_glyph(
|
||||
&mut self,
|
||||
glyph: &Glyph<'a>,
|
||||
transform: Affine,
|
||||
glyph_transform: Affine,
|
||||
paint: &hayro_interpret::Paint<'a>,
|
||||
draw_mode: &GlyphDrawMode,
|
||||
) {
|
||||
self.pending = false;
|
||||
fn draw_glyph_run(&mut self, glyph_run: &ShownGlyphs<'_, 'a>, props: DrawProps<'a>, draw_mode: &DrawMode) {
|
||||
// (A run is filled or stroked: one filled and stroked is given twice.)
|
||||
let stroke = match draw_mode {
|
||||
GlyphDrawMode::Invisible => return,
|
||||
GlyphDrawMode::Fill => None,
|
||||
GlyphDrawMode::Stroke(p) => Some(p.clone()),
|
||||
DrawMode::Invisible => return,
|
||||
DrawMode::Fill(_) => None,
|
||||
DrawMode::Stroke(p) | DrawMode::FillAndStroke(_, p) => Some(p),
|
||||
};
|
||||
match glyph {
|
||||
Glyph::Outline(o) => {
|
||||
let scale = mean_scale(transform);
|
||||
let overprint = self.glyph_overprint(stroke.is_some());
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
let mut bp = o.outline();
|
||||
bp.apply_affine(transform * glyph_transform);
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let same = self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == paint
|
||||
&& r.opacity == opacity
|
||||
&& r.overprint == overprint
|
||||
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
|
||||
&& (r.scale - scale).abs() < 1e-9
|
||||
});
|
||||
if !same {
|
||||
self.flush_glyphs();
|
||||
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint });
|
||||
}
|
||||
if let Some(r) = &mut self.glyphs {
|
||||
r.path.extend(bp.iter());
|
||||
}
|
||||
self.warn("text was converted to outlines");
|
||||
}
|
||||
Glyph::Type3(t3) => {
|
||||
if self.nested >= MAX_NESTED {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
self.nested += 1;
|
||||
t3.interpret(self, transform, glyph_transform, paint);
|
||||
self.nested -= 1;
|
||||
}
|
||||
for g in glyph_run.glyphs() {
|
||||
self.set_state(props.soft_mask.clone(), props.blend_mode);
|
||||
self.glyph(g, props.transform, g.transform(), &props.paint, stroke);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, props: ImageDrawProps<'a>) {
|
||||
self.set_state(props.soft_mask, props.blend_mode);
|
||||
let transform = props.transform;
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
@@ -1972,7 +2021,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn pop_clip_path(&mut self) {
|
||||
fn pop_clip(&mut self) {
|
||||
self.flush();
|
||||
self.pending = false;
|
||||
self.pop_frame();
|
||||
|
||||
@@ -379,7 +379,7 @@ impl<'p> Colors<'p> {
|
||||
}
|
||||
|
||||
/// An object written in place (references are visited as objects of their own).
|
||||
fn direct(m: MaybeRef<Object<'_>>) -> Option<Object<'_>> {
|
||||
pub(crate) fn direct(m: MaybeRef<Object<'_>>) -> Option<Object<'_>> {
|
||||
match m {
|
||||
MaybeRef::NotRef(o) => Some(o),
|
||||
MaybeRef::Ref(_) => None,
|
||||
|
||||
@@ -485,7 +485,8 @@ impl Walker<'_, '_> {
|
||||
{
|
||||
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
|
||||
}
|
||||
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
|
||||
// (Its own resources, or without any those of what draws it: as the interpreter reads them.)
|
||||
let inner = d.get::<Dict<'_>>(b"Resources").map(Resources::new).unwrap_or_else(|| res.clone());
|
||||
self.save();
|
||||
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
|
||||
self.restore();
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) ->
|
||||
let space = colors.shading_space(cs, at(0.0)?.len());
|
||||
// RGB to decide which samples are stops; the stops keep the shading's model.
|
||||
let sample = |t: f32| -> Option<Color> {
|
||||
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components();
|
||||
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0).components();
|
||||
Some(Color::rgb(round3(r), round3(g), round3(b)))
|
||||
};
|
||||
// Sample densely, then drop samples that linear interpolation reproduces.
|
||||
@@ -147,7 +147,7 @@ fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern
|
||||
};
|
||||
let space = colors.shading_space(&sp.shading.color_space, comps.len());
|
||||
colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| {
|
||||
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components();
|
||||
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0).components();
|
||||
Color::rgb(round3(r), round3(g), round3(b))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ mod post;
|
||||
mod presets;
|
||||
mod print;
|
||||
mod settings;
|
||||
mod streams;
|
||||
mod syntax;
|
||||
|
||||
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
|
||||
|
||||
+134
-1
@@ -1,12 +1,16 @@
|
||||
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
|
||||
//! is cropped to ([`CropTo`]) and [`info`].
|
||||
|
||||
use hayro_syntax::object::{Dict, Stream};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream};
|
||||
use hayro_syntax::page::Page;
|
||||
use hayro_syntax::xref::XRef;
|
||||
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
|
||||
use kurbo::{Affine, Rect};
|
||||
use vectorcraft_doc::setup::MAX_BLEED;
|
||||
|
||||
use crate::import_color::direct;
|
||||
use crate::{Choice, CropTo, ImportOptions, PdfError};
|
||||
|
||||
/// What `info` tells about one page.
|
||||
@@ -33,12 +37,135 @@ pub fn is_postscript(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6])
|
||||
}
|
||||
|
||||
/// The most functions and colour spaces that may be built on one another (a function stitching
|
||||
/// functions together, an indexed space on a space with an alternate). The interpreter (hayro)
|
||||
/// builds them by recursion, without a cap of its own: one built on itself, or thousands in a
|
||||
/// chain, overflows the stack, which is an abort, not an error. Files have two or three.
|
||||
const MAX_LINKS: usize = 16;
|
||||
|
||||
/// The deepest the procedures of a calculator function may nest: read by recursion, too.
|
||||
const MAX_PROCEDURES: usize = 64;
|
||||
|
||||
/// What the function or colour space `o` is built on: the functions a stitching function joins;
|
||||
/// the base, alternate and tint of a colour space; the alternate of a colour profile. Nothing for
|
||||
/// any other object.
|
||||
fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec<MaybeRef<Object<'a>>> {
|
||||
const SPACES: [&[u8]; 6] = [b"Indexed", b"I", b"Separation", b"DeviceN", b"Pattern", b"ICCBased"];
|
||||
if let Object::Array(a) = o {
|
||||
let mut items = a.raw_iter();
|
||||
let kind = match items.next() {
|
||||
Some(MaybeRef::NotRef(Object::Name(n))) => Some(n),
|
||||
Some(MaybeRef::Ref(r)) => xref.get::<Name<'a>>(r.into()),
|
||||
_ => None,
|
||||
};
|
||||
return if kind.is_some_and(|n| SPACES.contains(&n.as_ref())) { items.collect() } else { vec![] };
|
||||
}
|
||||
let Some((d, _)) = dict_or_stream(o) else { return vec![] };
|
||||
let functions = d.get::<Array<'a>>(b"Functions").map(|a| a.raw_iter().collect::<Vec<_>>()).unwrap_or_default();
|
||||
functions.into_iter().chain(d.get_raw::<Object<'a>>(b"Alternate")).collect()
|
||||
}
|
||||
|
||||
/// How many of its kind the function or colour space `o` is built on, one on the next (0 for any
|
||||
/// other object); `None` past `room`, or when it is built on itself. `known`: the objects looked
|
||||
/// at so far (`None` while one is being looked at, too). One `None` refuses the file, so that an
|
||||
/// object is `None` only for the room there was where it was first met doesn't matter.
|
||||
fn built_on<'a>(o: MaybeRef<Object<'a>>, xref: &'a XRef, known: &mut HashMap<ObjectIdentifier, Option<usize>>, room: usize) -> Option<usize> {
|
||||
let (id, o) = match o {
|
||||
MaybeRef::Ref(r) => {
|
||||
let id = ObjectIdentifier::from(r);
|
||||
if let Some(found) = known.get(&id) {
|
||||
return *found;
|
||||
}
|
||||
let Some(o) = xref.get::<Object<'a>>(id) else { return Some(0) };
|
||||
known.insert(id, None);
|
||||
(Some(id), o)
|
||||
}
|
||||
MaybeRef::NotRef(o) => (None, o),
|
||||
};
|
||||
let links = links(&o, xref);
|
||||
let most = match room.checked_sub(1) {
|
||||
_ if links.is_empty() => Some(0),
|
||||
Some(room) => links.into_iter().try_fold(0, |most, on| built_on(on, xref, known, room).map(|n| most.max(n + 1))),
|
||||
None => None,
|
||||
};
|
||||
// (What it is built on may be known already, and deeper than there is room for here.)
|
||||
let most = most.filter(|n| *n <= room);
|
||||
if let Some(id) = id {
|
||||
known.insert(id, most);
|
||||
}
|
||||
most
|
||||
}
|
||||
|
||||
/// Is `o` a calculator function whose procedures nest deeper than [`MAX_PROCEDURES`]?
|
||||
fn procedures_too_deep(o: &Object<'_>) -> bool {
|
||||
let Object::Stream(s) = o else { return false };
|
||||
if s.dict().get::<u8>(b"FunctionType") != Some(4) {
|
||||
return false;
|
||||
}
|
||||
let Ok(program) = s.decoded() else { return false };
|
||||
let mut depth = 0usize;
|
||||
program.iter().any(|c| {
|
||||
match c {
|
||||
b'{' => depth += 1,
|
||||
b'}' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
depth > MAX_PROCEDURES
|
||||
})
|
||||
}
|
||||
|
||||
/// The error of a file with a stream the reader would fail on ([`crate::streams`]).
|
||||
const STREAM_NOTE: &str = "a stream in it would decode to more than 256 MB, or its decode parameters can't be applied";
|
||||
|
||||
/// The error of a file [`nests_without_end`].
|
||||
const NESTED_NOTE: &str = "its functions or colour spaces are built on one another without end";
|
||||
|
||||
/// Why `pdf` is refused, if it is: a stream the reader would fail on (its own checks come first:
|
||||
/// the calculator functions are decoded below), or [`nests_without_end`].
|
||||
fn hostile(pdf: &Pdf) -> Option<&'static str> {
|
||||
if pdf.objects().into_iter().any(|o| matches!(&o, Object::Stream(s) if crate::streams::unsafe_stream(s))) {
|
||||
return Some(STREAM_NOTE);
|
||||
}
|
||||
nests_without_end(pdf).then_some(NESTED_NOTE)
|
||||
}
|
||||
|
||||
/// Does `pdf` hold functions or colour spaces built on one another past [`MAX_LINKS`] (or on
|
||||
/// themselves), or a calculator function nested past [`MAX_PROCEDURES`]? Every object is looked
|
||||
/// at, wherever it is used from: the interpreter builds them from resources, shadings, images,
|
||||
/// patterns, masks and graphics states alike.
|
||||
fn nests_without_end(pdf: &Pdf) -> bool {
|
||||
let xref = pdf.xref();
|
||||
let mut known = HashMap::new();
|
||||
// The objects of the file, then what each holds directly (64 deep at most: the reader's cap).
|
||||
let mut pending: Vec<Object<'_>> = pdf.objects().into_iter().collect();
|
||||
while let Some(o) = pending.pop() {
|
||||
if procedures_too_deep(&o) || built_on(MaybeRef::NotRef(o.clone()), xref, &mut known, MAX_LINKS).is_none() {
|
||||
return true;
|
||||
}
|
||||
match &o {
|
||||
Object::Array(a) => pending.extend(a.raw_iter().filter_map(direct)),
|
||||
_ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| d.entries()).filter_map(|(_, v)| direct(v))),
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions
|
||||
/// password opens a file too.
|
||||
///
|
||||
/// What a hostile file nests or loops is the reader's to refuse (hayro 0.8 reads arrays and
|
||||
/// dictionaries 64 deep and no deeper, wherever they are: the file's text, an object stream, the
|
||||
/// content of a page, form, pattern or glyph; and follows a page tree or a chain of object streams
|
||||
/// once). What it reads beyond that is left out, as an object it can't read. What the interpreter
|
||||
/// builds by recursion with no cap, and streams it would decode without a ceiling or with a
|
||||
/// predictor it can't apply, are refused here ([`hostile`], [`crate::streams`]).
|
||||
pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError> {
|
||||
if is_postscript(bytes) {
|
||||
return Err(PdfError::PostScript);
|
||||
}
|
||||
if crate::streams::unsafe_file_streams(bytes) {
|
||||
return Err(PdfError::Parse(STREAM_NOTE.into()));
|
||||
}
|
||||
let password = password.unwrap_or_default();
|
||||
let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw);
|
||||
read(password)
|
||||
@@ -55,6 +182,12 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError
|
||||
LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword,
|
||||
e => PdfError::Parse(format!("{e:?}")),
|
||||
})
|
||||
// Twice: looking at every object makes the reader repair a damaged table of objects (once
|
||||
// only), and the objects are other ones after that.
|
||||
.and_then(|pdf| match (0..2).find_map(|_| hostile(&pdf)) {
|
||||
Some(why) => Err(PdfError::Parse(why.into())),
|
||||
None => Ok(pdf),
|
||||
})
|
||||
}
|
||||
|
||||
/// The pages and their boxes. An encrypted PDF needs its `password`
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Streams looked at before the PDF reader (hayro) decodes them. It inflates Flate and LZW data
|
||||
//! with no ceiling, so a few megabytes can ask for gigabytes (#1700), and it applies a predictor
|
||||
//! before checking its parameters: `Columns 0` divides by zero, a huge `Columns` allocates a row
|
||||
//! that large whatever the stream holds (#1701). A file with such a stream is refused instead.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Read;
|
||||
|
||||
use hayro_syntax::content::TypedIter;
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, Stream};
|
||||
|
||||
/// The most a stream may decode to: 256 MB, the pixels of the largest image the app reads (64
|
||||
/// million, 4 bytes each).
|
||||
pub(crate) const MAX_DECODED: usize = 256 << 20;
|
||||
|
||||
/// The longest row a predictor may have, in bytes.
|
||||
const MAX_ROW: usize = 1 << 24;
|
||||
|
||||
/// A stream that decodes to more than [`MAX_DECODED`].
|
||||
struct TooBig;
|
||||
|
||||
/// The filters of the stream with dictionary `dict`, by name (`Filter` or `F`: a name or an array).
|
||||
fn filters<'a>(dict: &Dict<'a>) -> Vec<Name<'a>> {
|
||||
[&b"Filter"[..], b"F"]
|
||||
.into_iter()
|
||||
.find_map(|key| dict.get::<Name<'a>>(key).map(|n| vec![n]).or_else(|| dict.get::<Array<'a>>(key).map(|a| a.iter::<Name<'a>>().collect())))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Every decode parameter dictionary of the stream with dictionary `dict`, as the reader may take
|
||||
/// them (`DecodeParms` or `DP`: a dictionary, or an array with one for each filter).
|
||||
fn params<'a>(dict: &Dict<'a>) -> Vec<Dict<'a>> {
|
||||
let mut all = vec![];
|
||||
for key in [&b"DecodeParms"[..], b"DP"] {
|
||||
all.extend(dict.get::<Dict<'a>>(key));
|
||||
all.extend(dict.get::<Array<'a>>(key).into_iter().flat_map(|a| a.iter::<Object<'a>>().filter_map(|o| o.into_dict()).collect::<Vec<_>>()));
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// Would the reader's predictor fail on `params`? It reads `Colors` and `BitsPerComponent` as
|
||||
/// bytes and `Columns` as a size, multiplies the first two as bytes, divides by the length of a row
|
||||
/// and allocates one before it checks anything.
|
||||
fn bad_predictor(params: &Dict<'_>) -> bool {
|
||||
if params.get::<u8>(b"Predictor").unwrap_or(1) == 1 {
|
||||
return false;
|
||||
}
|
||||
let colors = usize::from(params.get::<u8>(b"Colors").unwrap_or(1));
|
||||
let bits = usize::from(params.get::<u8>(b"BitsPerComponent").unwrap_or(8));
|
||||
let columns = params.get::<usize>(b"Columns").unwrap_or(1);
|
||||
let pixel = colors * bits;
|
||||
let row = columns.checked_mul(pixel).map(|b| b.div_ceil(8));
|
||||
!(matches!(bits, 1 | 2 | 4 | 8 | 16) && colors > 0 && pixel <= 255 && columns > 0 && row.is_some_and(|r| r <= MAX_ROW))
|
||||
}
|
||||
|
||||
/// `data` inflated, up to one byte past [`MAX_DECODED`] (zlib, or raw deflate as the reader also
|
||||
/// takes); `None` when it is damaged before that.
|
||||
fn inflate(data: &[u8]) -> Option<Vec<u8>> {
|
||||
let read = |mut r: Box<dyn Read + '_>| {
|
||||
let mut out = vec![];
|
||||
// (Damaged data: what came out before the damage still counts.)
|
||||
let done = r.by_ref().take(MAX_DECODED as u64 + 1).read_to_end(&mut out).is_ok();
|
||||
(done || out.len() > MAX_DECODED).then_some(out)
|
||||
};
|
||||
read(Box::new(flate2::read::ZlibDecoder::new(data))).or_else(|| read(Box::new(flate2::read::DeflateDecoder::new(data))))
|
||||
}
|
||||
|
||||
/// `data` decoded from LZW, up to one byte past [`MAX_DECODED`]. `early`: the code length grows
|
||||
/// one code early (`EarlyChange`, 1 by default).
|
||||
fn lzw(data: &[u8], early: bool) -> Option<Vec<u8>> {
|
||||
use weezl::{BitOrder, LzwStatus, decode::Decoder};
|
||||
let mut decoder = if early { Decoder::with_tiff_size_switch(BitOrder::Msb, 8) } else { Decoder::new(BitOrder::Msb, 8) };
|
||||
let (mut out, mut chunk, mut input) = (vec![], vec![0; 1 << 16], data);
|
||||
while out.len() <= MAX_DECODED {
|
||||
let r = decoder.decode_bytes(input, &mut chunk);
|
||||
input = input.get(r.consumed_in..).unwrap_or_default();
|
||||
out.extend_from_slice(chunk.get(..r.consumed_out).unwrap_or_default());
|
||||
match r.status {
|
||||
Ok(LzwStatus::Ok) => {}
|
||||
Ok(LzwStatus::Done | LzwStatus::NoProgress) => break,
|
||||
Err(_) => return (out.len() > MAX_DECODED).then_some(out),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `data` decoded from run-length encoding, up to one byte past [`MAX_DECODED`].
|
||||
fn run_length(data: &[u8]) -> Vec<u8> {
|
||||
let (mut out, mut at) = (vec![], 0);
|
||||
while let Some(&n) = data.get(at)
|
||||
&& n != 128
|
||||
&& out.len() <= MAX_DECODED
|
||||
{
|
||||
if n < 128 {
|
||||
let take = usize::from(n) + 1;
|
||||
out.extend_from_slice(data.get(at + 1..).unwrap_or_default().get(..take).unwrap_or_default());
|
||||
at += 1 + take;
|
||||
} else {
|
||||
out.extend(std::iter::repeat_n(data.get(at + 1).copied().unwrap_or(0), 257 - usize::from(n)));
|
||||
at += 2;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `data` decoded from hexadecimal digits (white space skipped, up to `>`).
|
||||
fn hex(data: &[u8]) -> Vec<u8> {
|
||||
let digits: Vec<u8> = data.iter().take_while(|c| **c != b'>').filter_map(|c| char::from(*c).to_digit(16)).map(|d| d as u8).collect();
|
||||
digits.chunks(2).map(|p| (p.first().copied().unwrap_or(0) << 4) | p.get(1).copied().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
/// `data` decoded from base-85 (white space skipped, `z` for four zeros, up to `~>`).
|
||||
fn base85(data: &[u8]) -> Vec<u8> {
|
||||
let (mut out, mut group) = (vec![], vec![]);
|
||||
let flush = |group: &mut Vec<u32>, out: &mut Vec<u8>| {
|
||||
let n = group.len();
|
||||
if n > 1 {
|
||||
group.resize(5, 84);
|
||||
let v = group.iter().fold(0u32, |v, d| v.wrapping_mul(85).wrapping_add(*d));
|
||||
out.extend_from_slice(v.to_be_bytes().get(..n - 1).unwrap_or_default());
|
||||
}
|
||||
group.clear();
|
||||
};
|
||||
for &c in data.iter().take_while(|c| **c != b'~') {
|
||||
match c {
|
||||
b'z' if group.is_empty() => out.extend([0; 4]),
|
||||
b'!'..=b'u' => {
|
||||
group.push(u32::from(c - b'!'));
|
||||
if group.len() == 5 {
|
||||
flush(&mut group, &mut out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(&mut group, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The most data of `len` bytes can grow to through `filters`: 1,032 times through Flate (its
|
||||
/// largest ratio), 3,641 through LZW (4,096 bytes from a 9-bit code), 64 through run-length.
|
||||
fn most_after(len: usize, filters: &[Name<'_>]) -> u128 {
|
||||
filters.iter().fold(len as u128, |n, f| {
|
||||
n.saturating_mul(match f.as_ref() {
|
||||
b"FlateDecode" | b"Fl" => 1032,
|
||||
b"LZWDecode" | b"LZW" => 3641,
|
||||
b"RunLengthDecode" | b"RL" => 64,
|
||||
_ => 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `data` (of the stream with dictionary `dict`) through its filters, as far as this reads them:
|
||||
/// Flate, LZW, run-length and the ASCII encodings. Not decoded when it can't decode past
|
||||
/// [`MAX_DECODED`] and `keep` doesn't ask for it, or when an image codec or damaged data comes
|
||||
/// first (an image's own decoder knows its size).
|
||||
fn decode(data: &[u8], dict: &Dict<'_>, keep: bool) -> Result<Option<Vec<u8>>, TooBig> {
|
||||
let filters = filters(dict);
|
||||
if !keep && most_after(data.len(), &filters) <= MAX_DECODED as u128 {
|
||||
return Ok(None);
|
||||
}
|
||||
let params = params(dict);
|
||||
let mut data = Cow::Borrowed(data);
|
||||
for (i, f) in filters.iter().enumerate() {
|
||||
let early = params.get(i).and_then(|p| p.get::<u8>(b"EarlyChange")).is_none_or(|e| e != 0);
|
||||
let out = match f.as_ref() {
|
||||
b"FlateDecode" | b"Fl" => inflate(&data),
|
||||
b"LZWDecode" | b"LZW" => lzw(&data, early),
|
||||
b"RunLengthDecode" | b"RL" => Some(run_length(&data)),
|
||||
b"ASCIIHexDecode" | b"AHx" => Some(hex(&data)),
|
||||
b"ASCII85Decode" | b"A85" => Some(base85(&data)),
|
||||
_ => None,
|
||||
};
|
||||
match out {
|
||||
Some(out) if out.len() > MAX_DECODED => return Err(TooBig),
|
||||
Some(out) => data = Cow::Owned(out),
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
Ok(Some(data.into_owned()))
|
||||
}
|
||||
|
||||
/// May the stream with dictionary `dict` be content (of a page, form, pattern or glyph), which can
|
||||
/// hold inline images? Not an image, font, profile, function, metadata or object stream.
|
||||
fn maybe_content(dict: &Dict<'_>) -> bool {
|
||||
let named = |key: &[u8], ok: &[&[u8]]| dict.get::<Name<'_>>(key).is_none_or(|n| ok.contains(&n.as_ref()));
|
||||
named(b"Subtype", &[b"Form"])
|
||||
&& named(b"Type", &[b"XObject", b"Pattern"])
|
||||
&& ![&b"Length1"[..], b"Length2", b"Length3", b"FunctionType", b"N"].iter().any(|k| dict.contains_key(k))
|
||||
}
|
||||
|
||||
/// Would the reader fail on `stream` (or on an inline image in it, when it is content): a
|
||||
/// predictor it can't apply, or more than [`MAX_DECODED`] decoded?
|
||||
pub(crate) fn unsafe_stream(stream: &Stream<'_>) -> bool {
|
||||
unsafe_data(stream, true)
|
||||
}
|
||||
|
||||
/// [`unsafe_stream`], looking for inline images in it when `inside` (not in an inline image's own
|
||||
/// data: it would be read again for each inline image a hostile one nests).
|
||||
fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool {
|
||||
let dict = stream.dict();
|
||||
if params(dict).iter().any(bad_predictor) {
|
||||
return true;
|
||||
}
|
||||
let content = inside && maybe_content(dict);
|
||||
match decode(&stream.raw_data(), dict, content) {
|
||||
Err(TooBig) => true,
|
||||
Ok(Some(data)) if content && data.windows(2).any(|w| w == b"ID") => {
|
||||
let mut ops = TypedIter::new(&data);
|
||||
while let Some(op) = ops.next() {
|
||||
if let TypedInstruction::InlineImage(image) = op
|
||||
&& unsafe_data(image.0, false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
Ok(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Would the reader fail on a stream it decodes while it opens `pdf`, before anything else can
|
||||
/// look at it: an object stream or a cross-reference stream (read here from the file's text)?
|
||||
/// Their decode parameters must be in their dictionary, as the reader can't be asked for others
|
||||
/// yet. Encrypted object streams are looked at once the file is open (their data is ciphered
|
||||
/// here).
|
||||
pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool {
|
||||
let find = |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at);
|
||||
let mut from = 0;
|
||||
while let Some(at) = find(from, b"stream") {
|
||||
from = at + 6;
|
||||
if at >= 3 && pdf.get(at - 3..at) == Some(b"end") {
|
||||
continue;
|
||||
}
|
||||
// The dictionary before it: back from its `>>` to the `<<` that opens it.
|
||||
let Some(close) = pdf.get(..at).and_then(|head| head.iter().rposition(|c| !c.is_ascii_whitespace())).map(|i| i + 1) else { continue };
|
||||
if close < 2 || pdf.get(close - 2..close) != Some(b">>") {
|
||||
continue;
|
||||
}
|
||||
let (mut depth, mut open, mut i) = (0usize, None, close);
|
||||
while i >= 2 && close - i < 1 << 16 {
|
||||
match pdf.get(i - 2..i) {
|
||||
Some(b">>") => (depth, i) = (depth + 1, i - 2),
|
||||
Some(b"<<") => {
|
||||
depth -= 1;
|
||||
i -= 2;
|
||||
if depth == 0 {
|
||||
open = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => i -= 1,
|
||||
}
|
||||
}
|
||||
let Some(dict) = open.and_then(|o| pdf.get(o..close)).and_then(Dict::from_bytes) else { continue };
|
||||
if !dict.get::<Name<'_>>(b"Type").is_some_and(|t| matches!(t.as_ref(), b"ObjStm" | b"XRef")) {
|
||||
continue;
|
||||
}
|
||||
let indirect = [&b"DecodeParms"[..], b"DP"].iter().any(|k| matches!(dict.get_raw::<Object<'_>>(k), Some(MaybeRef::Ref(_))));
|
||||
if indirect || params(&dict).iter().any(bad_predictor) {
|
||||
return true;
|
||||
}
|
||||
let start = from
|
||||
+ usize::from(pdf.get(from) == Some(&b'\r'))
|
||||
+ usize::from(pdf.get(from + usize::from(pdf.get(from) == Some(&b'\r'))) == Some(&b'\n'));
|
||||
let end = dict
|
||||
.get::<usize>(b"Length")
|
||||
.and_then(|n| start.checked_add(n))
|
||||
.filter(|e| *e <= pdf.len())
|
||||
.or_else(|| find(start, b"endstream"))
|
||||
.unwrap_or(pdf.len());
|
||||
if matches!(decode(pdf.get(start..end).unwrap_or_default(), &dict, false), Err(TooBig)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dict(text: &str) -> Dict<'_> {
|
||||
Dict::from_bytes(text.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predictors_the_reader_can_apply_pass() {
|
||||
for ok in [
|
||||
"<< >>",
|
||||
"<< /Predictor 1 /Columns 0 >>",
|
||||
"<< /Predictor 12 /Columns 5 >>",
|
||||
"<< /Predictor 2 /Colors 3 /BitsPerComponent 16 /Columns 1000 >>",
|
||||
] {
|
||||
assert!(!bad_predictor(&dict(ok)), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"<< /Predictor 2 /Columns 0 >>",
|
||||
"<< /Predictor 12 /Colors 0 >>",
|
||||
"<< /Predictor 2 /BitsPerComponent 0 >>",
|
||||
"<< /Predictor 2 /BitsPerComponent 3 >>",
|
||||
"<< /Predictor 12 /Colors 32 /BitsPerComponent 16 >>",
|
||||
"<< /Predictor 15 /Columns 99999999999 >>",
|
||||
"<< /Predictor 10 /Columns 18446744073709551615 /Colors 4 >>",
|
||||
] {
|
||||
assert!(bad_predictor(&dict(bad)), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decoders_read_what_the_specification_writes() {
|
||||
assert_eq!(hex(b"48 65 6c6C6\n>junk"), b"Hell`");
|
||||
assert_eq!(base85(b"87cURD]i,\"Ebo80~>"), b"Hello World!");
|
||||
assert_eq!(base85(b"z!!~>"), [0, 0, 0, 0, 0]);
|
||||
assert_eq!(run_length(&[2, b'a', b'b', b'c', 254, b'x', 128, b'q']), b"abcxxx");
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut e, &[7; 1000]).unwrap();
|
||||
assert_eq!(inflate(&e.finish().unwrap()).unwrap(), [7; 1000]);
|
||||
// "-----A---B" in LZW, as the specification's example writes it (7.4.4.2).
|
||||
let lzw_example = [0x80, 0x0B, 0x60, 0x50, 0x22, 0x0C, 0x0C, 0x85, 0x01];
|
||||
assert_eq!(lzw(&lzw_example, true).unwrap(), b"-----A---B");
|
||||
}
|
||||
|
||||
/// Zeros past the cap, compressed.
|
||||
fn zeros_past_the_cap() -> Vec<u8> {
|
||||
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
for _ in 0..(MAX_DECODED >> 20) + 1 {
|
||||
std::io::Write::write_all(&mut e, &[0; 1 << 20]).unwrap();
|
||||
}
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_that_decodes_past_the_cap_is_found_without_holding_it() {
|
||||
// Inflated in pieces: never more than one byte past the cap is held.
|
||||
let bomb = zeros_past_the_cap();
|
||||
assert!(bomb.len() < MAX_DECODED / 500);
|
||||
assert_eq!(inflate(&bomb).map(|d| d.len()), Some(MAX_DECODED + 1));
|
||||
let d = dict("<< /Filter /FlateDecode >>");
|
||||
assert!(matches!(decode(&bomb, &d, false), Err(TooBig)));
|
||||
// Small enough that it can't get there: not decoded at all.
|
||||
assert!(matches!(decode(&[1, 2, 3], &d, false), Ok(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_and_cross_reference_streams_are_looked_at_before_the_file_is_read() {
|
||||
let stream = |dict: &str, data: &[u8]| {
|
||||
[format!("1 0 obj\n<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream\nendobj\n"].concat()
|
||||
};
|
||||
for bad in [
|
||||
stream("/Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Columns 0 >>", b"x"),
|
||||
stream("/Type /XRef /Filter /FlateDecode /DecodeParms 9 0 R", b"x"),
|
||||
stream("/Type /ObjStm /N 1 /First 4 /Filter /FlateDecode", &zeros_past_the_cap()),
|
||||
] {
|
||||
assert!(unsafe_file_streams(&bad));
|
||||
}
|
||||
for fine in [
|
||||
stream("/Type /XRef /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 5 >>", b"x"),
|
||||
// Another kind of stream is looked at once the file is open, its references followed.
|
||||
stream("/Filter /FlateDecode /DecodeParms << /Predictor 2 /Columns 0 >>", b"x"),
|
||||
] {
|
||||
assert!(!unsafe_file_streams(&fine));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -734,3 +734,46 @@ fn opacity_mask_exports_as_luminosity_soft_mask() {
|
||||
assert!(text.contains("/SMask"), "soft mask in an ExtGState");
|
||||
assert!(text.contains("/Luminosity"));
|
||||
}
|
||||
|
||||
/// A function or colour space built on itself is refused before the interpreter builds it (by
|
||||
/// recursion, with no cap: a stack overflow), and those of real files are read.
|
||||
#[test]
|
||||
fn functions_and_colour_spaces_built_on_themselves_are_refused() {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
let first = first_extra(1);
|
||||
let (me, next) = (format!("{first} 0 R"), format!("{} 0 R", first + 1));
|
||||
let shaded = PdfPage {
|
||||
resources: format!("/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function {me} >> >>"),
|
||||
..PdfPage::new(100.0, 100.0, "/S sh")
|
||||
};
|
||||
let filled = PdfPage { resources: format!("/ColorSpace << /C {me} >>"), ..PdfPage::new(100.0, 100.0, "/C cs 0 sc 0 0 50 50 re f") };
|
||||
let stitch = |on: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{on}] /Bounds [] /Encode [0 1] >>");
|
||||
let indexed = |on: &str| format!("[/Indexed {on} 1 <000000ffffff>]");
|
||||
let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>";
|
||||
// `n` functions, each stitching the next, then a plain one.
|
||||
let chain = |n: usize| (1..=n).map(|i| stitch(&format!("{} 0 R", first + i))).chain([ramp.to_string()]).collect::<Vec<_>>();
|
||||
let calculator =
|
||||
|program: &str| format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>\nstream\n{program}\nendstream", program.len());
|
||||
for (what, page, objects) in [
|
||||
("a function stitching itself", &shaded, vec![stitch(&me)]),
|
||||
("two functions stitching each other", &shaded, vec![stitch(&next), stitch(&me)]),
|
||||
("twenty functions, each stitching the next", &shaded, chain(20)),
|
||||
("procedures in procedures", &shaded, vec![calculator(&format!("{}{}", "{".repeat(100), "}".repeat(100)))]),
|
||||
("a colour space on itself", &filled, vec![indexed(&me)]),
|
||||
("two colour spaces on each other", &filled, vec![indexed(&next), indexed(&me)]),
|
||||
] {
|
||||
let objects: Vec<&str> = objects.iter().map(String::as_str).collect();
|
||||
let e = import(&pdf_with(std::slice::from_ref(page), &objects, None)).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("built on one another without end")), "{what}: {e:?}");
|
||||
}
|
||||
for (what, page, objects) in [
|
||||
("a function stitching one", &shaded, vec![stitch(&next), ramp.to_string()]),
|
||||
("three functions, each stitching the next", &shaded, chain(3)),
|
||||
("a calculator function", &shaded, vec![calculator("{ dup 1 exch sub 0 }")]),
|
||||
("an indexed colour space", &filled, vec![indexed("/DeviceRGB")]),
|
||||
] {
|
||||
let objects: Vec<&str> = objects.iter().map(String::as_str).collect();
|
||||
let d = import(&pdf_with(std::slice::from_ref(page), &objects, None)).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "{what}: its art is read");
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,7 @@ fn a_sublayer_that_paints_first_brings_its_parent_layer() {
|
||||
#[test]
|
||||
fn hostile_layer_orders_stay_bounded() {
|
||||
// A group listed twice, under itself, and an order nested far deeper than layers go.
|
||||
let deep = format!("{}{{2}}{}", "[".repeat(100), "]".repeat(100));
|
||||
let deep = format!("{}{{2}}{}", "[".repeat(40), "]".repeat(40));
|
||||
let order = format!("[{{0}} [{{0}} {{1}}] {{1}} [{{0}}] {deep}]");
|
||||
let d = import(&three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], &format!("/Order {order}"))).unwrap();
|
||||
assert_eq!(names(&d), ["A", "C"], "C, listed too deep, stays a top-level layer");
|
||||
@@ -317,7 +317,8 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() {
|
||||
/// Import `bytes` with the scan's operator limit lowered to `n`.
|
||||
fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport {
|
||||
let old = crate::import_scan::TEST_MAX_OPS.replace(n);
|
||||
let r = import_with_report(bytes, &ImportOptions::default());
|
||||
// (On this thread: the limit is this thread's.)
|
||||
let r = crate::import::read(bytes, &ImportOptions::default());
|
||||
crate::import_scan::TEST_MAX_OPS.set(old);
|
||||
r.unwrap()
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ impl Renderer {
|
||||
let pieces = match self.brushes.entries.get(&key, stamp) {
|
||||
Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(),
|
||||
_ => {
|
||||
let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st));
|
||||
let pieces = Arc::new(vectorcraft_brush::stroke_pieces(f.doc, &brush, bp, st));
|
||||
let bytes = size_of::<Entry>() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::<usize>();
|
||||
self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp);
|
||||
pieces
|
||||
|
||||
+81
-18
@@ -366,6 +366,29 @@ fn linear_key(view: Affine) -> [u64; 4] {
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content
|
||||
/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height,
|
||||
/// scale). It covers what of the reach can show in the view, the blur's spread around it
|
||||
/// included, at `scale` raster pixels a device pixel: 1, or less to stay within
|
||||
/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a
|
||||
/// side, whatever the size of the view (#1706). `None`: nothing to draw.
|
||||
fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> {
|
||||
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
|
||||
let spread = blur.max(0.0) * 1.5 + 2.0;
|
||||
let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread);
|
||||
let r = reach.inflate(spread, spread).intersect(screen);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let (rw, rh) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
|
||||
// (Also false for a reach or a blur that isn't a number.)
|
||||
if !(rw >= 1.0 && rh >= 1.0 && rw.is_finite() && rh.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let side = f64::from(u16::MAX);
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side));
|
||||
Some((x0, y0, w as u16, h as u16, k))
|
||||
}
|
||||
|
||||
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
|
||||
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
|
||||
let (w, h) = (image.width() as f64, image.height() as f64);
|
||||
@@ -509,7 +532,8 @@ impl Drawn {
|
||||
/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct Reads {
|
||||
/// Pattern paints: the pattern definitions.
|
||||
/// Pattern paints: the pattern definitions. Their art can hold anything, so content painted
|
||||
/// with a pattern reads everything else too.
|
||||
patterns: bool,
|
||||
/// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold
|
||||
/// anything, so content that shows a symbol reads everything else too.
|
||||
@@ -527,14 +551,25 @@ pub(crate) struct Reads {
|
||||
impl Reads {
|
||||
/// What the content of `n` reads.
|
||||
pub(crate) fn of(n: &Node) -> Self {
|
||||
Self::of_all(std::iter::once(n))
|
||||
}
|
||||
|
||||
/// What the content of `nodes` reads together. A pattern's art can hold anything, as a
|
||||
/// symbol's can, so content painted with a pattern reads everything else too (#1723).
|
||||
pub(crate) fn of_all<'a>(nodes: impl Iterator<Item = &'a Node>) -> Self {
|
||||
let mut reads = Self::default();
|
||||
reads.add(n);
|
||||
if reads.symbols {
|
||||
nodes.for_each(|n| reads.add(n));
|
||||
if reads.symbols || reads.patterns {
|
||||
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
|
||||
}
|
||||
reads
|
||||
}
|
||||
|
||||
/// Does the content read anything outside its own nodes?
|
||||
pub(crate) fn any(self) -> bool {
|
||||
self != Self::default()
|
||||
}
|
||||
|
||||
fn add(&mut self, n: &Node) {
|
||||
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
|
||||
n.walk(&mut |c| {
|
||||
@@ -869,10 +904,10 @@ impl Renderer {
|
||||
// A symbol's art can change while the instance doesn't: no cached shadows.
|
||||
let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0);
|
||||
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) };
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true));
|
||||
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
}
|
||||
_ => self.draw_node(ctx, f, &art, true),
|
||||
_ => self.draw_fx_art(ctx, f, a, &art),
|
||||
}
|
||||
self.made_again = outer;
|
||||
}
|
||||
@@ -939,6 +974,19 @@ impl Renderer {
|
||||
art
|
||||
}
|
||||
|
||||
/// Draw `art`, the art [`Self::fx_art`] made for `a`. Type art that `fx_arts` keeps for `a` is
|
||||
/// drawn as `fx_owner`, so [`Self::draw_text`] takes its glyph geometry from `texts`.
|
||||
fn draw_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, art: &Arc<Node>) {
|
||||
let kept =
|
||||
matches!(art.kind, NodeKind::Text(_)) && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, art));
|
||||
if !kept {
|
||||
return self.draw_node(ctx, f, art, true);
|
||||
}
|
||||
let outer = self.fx_owner.replace(art.clone());
|
||||
self.draw_node(ctx, f, art, true);
|
||||
self.fx_owner = outer;
|
||||
}
|
||||
|
||||
/// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on
|
||||
/// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the
|
||||
/// decoded images under a key naming the adjustment.
|
||||
@@ -1352,18 +1400,9 @@ impl Renderer {
|
||||
if !f.mt {
|
||||
return self.inside_layer(|r| draw(r, ctx, f, composite));
|
||||
}
|
||||
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
|
||||
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
|
||||
let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread);
|
||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64));
|
||||
if w < 1.0 || h < 1.0 {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (w as u16, h as u16);
|
||||
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f };
|
||||
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
@@ -1373,7 +1412,12 @@ impl Renderer {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
|
||||
}
|
||||
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
|
||||
if k < 1.0 {
|
||||
draw_scaled(ctx, std::sync::Arc::new(pm), (x0, y0), k);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
} else {
|
||||
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
|
||||
}
|
||||
if layered {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
@@ -1469,9 +1513,28 @@ mod tests {
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::Drawn;
|
||||
use super::{Drawn, PIXEL_BUDGET, filter_region};
|
||||
use crate::{RenderOptions, Rendered, Renderer};
|
||||
|
||||
/// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however
|
||||
/// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB).
|
||||
#[test]
|
||||
fn a_wide_blur_at_a_high_zoom_is_filtered_within_the_pixel_budget() {
|
||||
// A 100 pt square at 3200 % and at 6400 %, blurred by 1000 pt, in a 1600 × 1000 view.
|
||||
for zoom in [32.0, 64.0] {
|
||||
let reach = Rect::new(0.0, 0.0, 100.0 * zoom, 100.0 * zoom);
|
||||
let (x0, y0, w, h, k) = filter_region(reach, (1600, 1000), 1000.0 * zoom).unwrap();
|
||||
assert!(f64::from(w) * f64::from(h) <= PIXEL_BUDGET, "{w} × {h}");
|
||||
assert!(k < 1.0 && x0 < 0.0 && y0 < 0.0);
|
||||
// It still covers the view and the blur's spread around it, at that scale.
|
||||
assert!(f64::from(w) / k >= 1600.0 + 1500.0 * zoom && f64::from(h) / k >= 1000.0 + 1500.0 * zoom);
|
||||
}
|
||||
// A small blur is filtered at full size, cropped to what can show.
|
||||
assert_eq!(filter_region(Rect::new(10.0, 20.0, 110.0, 70.0), (1600, 1000), 4.0), Some((2.0, 12.0, 116, 66, 1.0)));
|
||||
assert_eq!(filter_region(Rect::new(5000.0, 0.0, 5100.0, 100.0), (1600, 1000), 4.0), None, "off the view");
|
||||
assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0));
|
||||
}
|
||||
|
||||
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let id = d.alloc_id();
|
||||
|
||||
@@ -83,7 +83,8 @@ pub struct RenderOptions {
|
||||
pub artboards: bool,
|
||||
/// Objects not to draw (e.g. the object being edited by a live drag preview).
|
||||
pub hidden: Vec<NodeId>,
|
||||
/// Draw template layers dimmed (50%).
|
||||
/// Draw the layers as their Layer Options show them on screen: Preview off in outline, Dim
|
||||
/// Images fading their images (a template layer's too), a template in Preview in Outline view.
|
||||
pub dim_templates: bool,
|
||||
/// Soft proof / separations preview (see [`proof`]).
|
||||
pub proof: Option<proof::ProofSetup>,
|
||||
@@ -489,6 +490,9 @@ pub struct Renderer {
|
||||
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
|
||||
/// Drawing inside the isolated container or a dimmed object: nothing further dims.
|
||||
isolation_settled: bool,
|
||||
/// The art the last frame drew with Overprint Preview or Separations Preview: the next frame
|
||||
/// draws it again for the objects that have not changed ([`proof::prepare`]).
|
||||
proof_art: Option<proof::Prepared>,
|
||||
}
|
||||
|
||||
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
|
||||
@@ -591,6 +595,7 @@ impl Renderer {
|
||||
raster: vello_cpu::RasterizerSettings::default(),
|
||||
isolation: None,
|
||||
isolation_settled: false,
|
||||
proof_art: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +620,7 @@ impl Renderer {
|
||||
}
|
||||
// The document's own symbols: proofing rewrites the colors and blend modes of their art.
|
||||
self.check_symbols(doc);
|
||||
let prepared = proof::prepare(doc, opts);
|
||||
let prepared = proof::prepare(doc, opts, &mut self.proof_art);
|
||||
let doc: &Document = &prepared;
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
@@ -1088,8 +1093,8 @@ impl Renderer {
|
||||
return;
|
||||
}
|
||||
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
|
||||
// fades its images (exports and thumbnails, which leave templates out, ignore both).
|
||||
// (A template is dimmed as a whole already.)
|
||||
// fades its images, a template's too (exports and thumbnails, which leave templates out,
|
||||
// ignore both).
|
||||
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
|
||||
&& f.opts.dim_templates
|
||||
&& !f.opts.skip_templates
|
||||
@@ -1104,7 +1109,7 @@ impl Renderer {
|
||||
let opts = RenderOptions { outline: true, ..f.opts.clone() };
|
||||
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
|
||||
}
|
||||
if let Some(p) = dim_images.filter(|_| !template) {
|
||||
if let Some(p) = *dim_images {
|
||||
let outer = self.dim_images;
|
||||
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
|
||||
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
|
||||
@@ -1162,8 +1167,7 @@ impl Renderer {
|
||||
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
|
||||
}
|
||||
let outline = f.opts.outline;
|
||||
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
|
||||
let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) };
|
||||
let opacity = self.opacity_of(n);
|
||||
// Whether this container's children knock each other out.
|
||||
let knockout = !outline && n.knocks_out(self.knockout);
|
||||
let enclosing = std::mem::replace(&mut self.knockout, knockout);
|
||||
@@ -1602,7 +1606,14 @@ impl Renderer {
|
||||
|
||||
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
|
||||
let t = &*f.doc.inline_resolved(t);
|
||||
let g = text_geom_snapped(t, f.text_snap(t));
|
||||
// Type drawn as `fx_owner` (the node `draw_arc` is drawing, or type art that `fx_arts`
|
||||
// keeps, see `draw_fx_art`) takes its glyph geometry from `texts`, as on the fast path,
|
||||
// except in frames that snap type to pixels.
|
||||
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned();
|
||||
let g = match (f.text_snap(t), owner) {
|
||||
(None, Some(a)) => self.text_geom_of(&a, t),
|
||||
(snap, _) => Arc::new(text_geom_snapped(t, snap)),
|
||||
};
|
||||
self.draw_text_geom(ctx, f, n, t, &g);
|
||||
}
|
||||
|
||||
@@ -1670,7 +1681,12 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
|
||||
/// Text `t` that is not the node's own, such as the copy whose inline graphics
|
||||
/// `Document::inline_resolved` sized again, is laid out without the cache.
|
||||
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
|
||||
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
|
||||
return Arc::new(text_geom(t));
|
||||
}
|
||||
let fonts = vectorcraft_text::FontDb::global().generation();
|
||||
if fonts != self.text_fonts {
|
||||
self.texts.clear();
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
//! with the paint transform mapping pixels → pattern space → document. Tiles are cached per
|
||||
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
|
||||
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
|
||||
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision".
|
||||
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and,
|
||||
//! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while
|
||||
//! what the document holds of those is what it held then ([`Deps`]), and while the color settings
|
||||
//! are the ones it was drawn through.
|
||||
//!
|
||||
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
|
||||
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
|
||||
use vectorcraft_doc::{Document, Node};
|
||||
use vectorcraft_geom::{Affine, Rect, Shape};
|
||||
@@ -32,8 +36,54 @@ const MAX_TILE_PX: f64 = 2048.0;
|
||||
/// ([`raster_scale`]).
|
||||
type TileKey = (String, u64);
|
||||
|
||||
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
|
||||
/// the art of every pattern and symbol and the pixels of every image in the document, the
|
||||
/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art
|
||||
/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held
|
||||
/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses
|
||||
/// while the tile is kept.
|
||||
struct Deps {
|
||||
art: Vec<Weak<Node>>,
|
||||
pixels: Vec<Weak<Vec<u8>>>,
|
||||
/// shortcut: any change to the document's other data draws the tile again, not only a brush
|
||||
/// edit; compare the brush library alone if that ever costs a frame.
|
||||
unknown: Weak<vectorcraft_brush::serde_json::Map<String, vectorcraft_brush::serde_json::Value>>,
|
||||
fonts: u64,
|
||||
}
|
||||
|
||||
impl Deps {
|
||||
fn art(doc: &Document) -> impl Iterator<Item = &Arc<Node>> {
|
||||
doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art))
|
||||
}
|
||||
|
||||
fn pixels(doc: &Document) -> impl Iterator<Item = &Arc<Vec<u8>>> {
|
||||
doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy))
|
||||
}
|
||||
|
||||
fn of(doc: &Document) -> Self {
|
||||
Self {
|
||||
art: Self::art(doc).map(Arc::downgrade).collect(),
|
||||
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
|
||||
unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown),
|
||||
fonts: vectorcraft_text::FontDb::global().generation(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `doc` hold what the tile was drawn from?
|
||||
fn current(&self, doc: &Document) -> bool {
|
||||
self.fonts == vectorcraft_text::FontDb::global().generation()
|
||||
&& std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown)
|
||||
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
|
||||
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
|
||||
}
|
||||
}
|
||||
|
||||
struct TileEntry {
|
||||
art: Vec<Arc<Node>>,
|
||||
/// `None` for art that shows only itself.
|
||||
deps: Option<Deps>,
|
||||
/// The color settings the tile was drawn through, held weakly as [`Deps`] holds its values.
|
||||
cms: Weak<Cms>,
|
||||
params: (Rect, TileType, Overlap),
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
@@ -159,11 +209,20 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let scale = raster_scale(s, (pw, ph));
|
||||
let params = (def.tile, def.tile_type, def.overlap);
|
||||
let key = (name.to_string(), scale.to_bits());
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let hit = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let t = tiles.tick();
|
||||
tiles.get(&key, t).filter(|e| e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
tiles
|
||||
.get(&key, t)
|
||||
.filter(|e| {
|
||||
e.params == params
|
||||
&& same_art(&e.art, &def.art)
|
||||
&& std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms))
|
||||
&& e.deps.as_ref().is_none_or(|d| d.current(doc))
|
||||
})
|
||||
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
});
|
||||
let (pm, sx, sy) = match hit {
|
||||
Some(h) => h,
|
||||
@@ -176,11 +235,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
};
|
||||
let pm = Arc::new(pm);
|
||||
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
||||
let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc));
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
|
||||
tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||
tiles.insert(key, TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||
});
|
||||
(pm, sx, sy)
|
||||
}
|
||||
|
||||
+157
-21
@@ -18,14 +18,15 @@
|
||||
//! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`].
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock, Weak};
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
|
||||
pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_color::{Color, Swatch, SwatchGroup};
|
||||
pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
|
||||
use vectorcraft_doc::overprint::multiply_overprints;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Shared};
|
||||
|
||||
use crate::RenderOptions;
|
||||
|
||||
@@ -63,29 +64,164 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String],
|
||||
Color::Rgb { r, g, b }
|
||||
}
|
||||
|
||||
/// The document as it should be drawn for these options (overprints, separations).
|
||||
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> {
|
||||
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
|
||||
let overprint = overprints(opts) && (doc.layers.iter().any(|l| l.has_overprint()) || doc.symbols.iter().any(|s| s.art.has_overprint()));
|
||||
if !overprint && seps.is_none() {
|
||||
/// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame
|
||||
/// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for
|
||||
/// it, and what that art was made with. The kept nodes keep their addresses: no other node gets
|
||||
/// one of them while they are kept.
|
||||
pub(crate) struct Prepared {
|
||||
inputs: Inputs,
|
||||
layers: (Vec<Arc<Node>>, Vec<Arc<Node>>),
|
||||
art: (Vec<Arc<Node>>, Vec<Arc<Node>>),
|
||||
}
|
||||
|
||||
/// What [`prepare`] reads besides the art.
|
||||
#[derive(PartialEq)]
|
||||
struct Inputs {
|
||||
discard_white: bool,
|
||||
plates: Option<Plates>,
|
||||
}
|
||||
|
||||
/// What the colors of Separations Preview's plates are made from ([`plate_color`]).
|
||||
struct Plates {
|
||||
/// The proof setup, with the visible plates.
|
||||
proof: ProofSetup,
|
||||
/// The document's swatches, swatch groups and Spot Colors option.
|
||||
swatches: Shared<Vec<Swatch>>,
|
||||
groups: Shared<Vec<SwatchGroup>>,
|
||||
spot_use_lab: bool,
|
||||
/// The color settings, held weakly: no other settings get their address while they are kept.
|
||||
cms: Weak<Cms>,
|
||||
/// [`cms::profiles_generation`].
|
||||
profiles: u64,
|
||||
}
|
||||
|
||||
impl PartialEq for Plates {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
(&self.proof, &self.swatches, &self.groups, self.spot_use_lab, self.profiles)
|
||||
== (&o.proof, &o.swatches, &o.groups, o.spot_use_lab, o.profiles)
|
||||
&& Weak::ptr_eq(&self.cms, &o.cms)
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply
|
||||
/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates
|
||||
/// print it ([`plate_color`]).
|
||||
struct How<'a> {
|
||||
doc: &'a Document,
|
||||
discard_white: bool,
|
||||
/// Separations Preview: the color settings, the proof setup and the visible plates.
|
||||
plates: Option<(&'a Cms, &'a ProofSetup, &'a [String])>,
|
||||
}
|
||||
|
||||
impl How<'_> {
|
||||
/// Whether [`Self::apply`] can change `n`: with Separations Preview any node, else a node with
|
||||
/// an overprinting fill or stroke.
|
||||
fn changes(&self, n: &Node) -> bool {
|
||||
self.plates.is_some() || n.appearance.items.iter().any(|i| i.overprint())
|
||||
}
|
||||
|
||||
/// Change the fills, strokes and other colors of node `a`, which holds no members.
|
||||
fn apply(&self, a: &mut Arc<Node>) {
|
||||
multiply_overprints(a, self.discard_white);
|
||||
if let Some((c, proof, visible)) = self.plates {
|
||||
map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as it should be drawn for these options (overprints, separations). `kept` holds
|
||||
/// the art of an earlier frame ([`Prepared`]): when that frame's inputs were the same, each layer,
|
||||
/// symbol art and member that is still the node its kept art was made from is drawn from that art,
|
||||
/// with its `Arc`. `kept` then holds this frame's art, or nothing when neither preview is on.
|
||||
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions, kept: &mut Option<Prepared>) -> Cow<'a, Document> {
|
||||
if !overprints(opts) {
|
||||
*kept = None;
|
||||
return Cow::Borrowed(doc);
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
if overprint {
|
||||
let discard_white = doc.setup.discard_white_overprint;
|
||||
for l in &mut d.layers {
|
||||
multiply_overprints(l, discard_white);
|
||||
}
|
||||
for s in &mut d.symbols {
|
||||
multiply_overprints(&mut s.art, discard_white);
|
||||
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
|
||||
let c = cms::active();
|
||||
let inputs = Inputs {
|
||||
discard_white: doc.setup.discard_white_overprint,
|
||||
plates: seps.map(|(proof, _)| Plates {
|
||||
proof: proof.clone(),
|
||||
swatches: doc.swatches.clone(),
|
||||
groups: doc.swatch_groups.clone(),
|
||||
spot_use_lab: doc.spot_use_lab,
|
||||
cms: Arc::downgrade(&c),
|
||||
profiles: cms::profiles_generation(),
|
||||
}),
|
||||
};
|
||||
let earlier = kept.take().filter(|k| k.inputs == inputs);
|
||||
let how = How { doc, discard_white: inputs.discard_white, plates: seps.map(|(proof, visible)| (&*c, proof, visible.as_slice())) };
|
||||
let none = (vec![], vec![]);
|
||||
let (layers, art) = earlier.as_ref().map_or((&none, &none), |k| (&k.layers, &k.art));
|
||||
let made_layers = prepare_list(&doc.layers, &layers.0, &layers.1, &how);
|
||||
let symbol_art: Vec<Arc<Node>> = doc.symbols.iter().map(|s| s.art.clone()).collect();
|
||||
let made_art = prepare_list(&symbol_art, &art.0, &art.1, &how);
|
||||
let same = |made: &[Arc<Node>], from: &[Arc<Node>]| made.iter().zip(from).all(|(a, b)| Arc::ptr_eq(a, b));
|
||||
let out = if same(&made_layers, &doc.layers) && same(&made_art, &symbol_art) {
|
||||
Cow::Borrowed(doc)
|
||||
} else {
|
||||
let mut d = doc.clone();
|
||||
d.layers.clone_from(&made_layers);
|
||||
for (s, a) in d.symbols.iter_mut().zip(&made_art) {
|
||||
s.art = a.clone();
|
||||
}
|
||||
Cow::Owned(d)
|
||||
};
|
||||
*kept = Some(Prepared { inputs, layers: (doc.layers.clone(), made_layers), art: (symbol_art, made_art) });
|
||||
out
|
||||
}
|
||||
|
||||
/// `nodes` as drawn with `how` ([`prepare_node`]), each against its earlier version in `was`: the
|
||||
/// node at its place when that node has its id, else the node with its id. `made` holds the art
|
||||
/// drawn for `was`, place by place.
|
||||
fn prepare_list(nodes: &[Arc<Node>], was: &[Arc<Node>], made: &[Arc<Node>], how: &How) -> Vec<Arc<Node>> {
|
||||
let mut places: Option<HashMap<NodeId, usize>> = None;
|
||||
nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let at = match was.get(i) {
|
||||
Some(w) if w.id == n.id => Some(i),
|
||||
_ => places.get_or_insert_with(|| was.iter().enumerate().map(|(j, w)| (w.id, j)).collect()).get(&n.id).copied(),
|
||||
};
|
||||
prepare_node(n, at.and_then(|j| Some((was.get(j)?, made.get(j)?))), how)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `n` as drawn with `how`: the art of `earlier` (a node and the art drawn for it) when that art
|
||||
/// was made from `n` itself; else a copy of `n` with its own fills and strokes changed
|
||||
/// ([`How::apply`]) and its members prepared against those of `earlier` ([`prepare_list`]), or `n`
|
||||
/// itself when neither changes.
|
||||
fn prepare_node(n: &Arc<Node>, earlier: Option<(&Arc<Node>, &Arc<Node>)>, how: &How) -> Arc<Node> {
|
||||
if let Some((was, made)) = earlier
|
||||
&& Arc::ptr_eq(was, n)
|
||||
{
|
||||
return made.clone();
|
||||
}
|
||||
if let Some((proof, visible)) = seps {
|
||||
let c = cms::active();
|
||||
let src = doc.clone();
|
||||
map_document_colors(&mut d, &mut |col, sw| plate_color(&src, &c, proof, visible, col, sw));
|
||||
let members = n.children().map(|ch| {
|
||||
let (was, made) = earlier.and_then(|(w, m)| Some((w.children()?.as_slice(), m.children()?.as_slice()))).unwrap_or_default();
|
||||
prepare_list(ch, was, made, how)
|
||||
});
|
||||
let same_members = match (&members, n.children()) {
|
||||
(Some(m), Some(ch)) => m.iter().zip(ch).all(|(a, b)| Arc::ptr_eq(a, b)),
|
||||
_ => true,
|
||||
};
|
||||
if same_members && !how.changes(n) {
|
||||
return n.clone();
|
||||
}
|
||||
Cow::Owned(d)
|
||||
let mut own = Node::clone(n);
|
||||
if let Some(ch) = own.children_mut() {
|
||||
ch.clear();
|
||||
}
|
||||
let mut a = Arc::new(own);
|
||||
how.apply(&mut a);
|
||||
if let (Some(m), Some(ch)) = (members, Arc::make_mut(&mut a).children_mut()) {
|
||||
*ch = m;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Soft-proof the rendered (premultiplied RGBA8) pixels in place.
|
||||
|
||||
@@ -83,6 +83,37 @@ fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
/// available: the glyph geometry kept for it is laid out again.
|
||||
#[test]
|
||||
fn translucent_blended_and_shadowed_type_redraws_when_its_font_becomes_available() {
|
||||
const FAMILY: &str = "Blended Serif4";
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..3 {
|
||||
let style = CharStyle { size: 36.0, font_family: FAMILY.into(), ..Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 40.0 + 45.0 * i as f64), "Hamburg", style))));
|
||||
match i {
|
||||
0 => n.opacity = 0.8,
|
||||
1 => n.blend = vectorcraft_color::BlendMode::Multiply,
|
||||
_ => n.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
params: serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}),
|
||||
visible: true,
|
||||
}),
|
||||
}
|
||||
d.insert(Some(l), i, n).unwrap();
|
||||
}
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert_eq!(r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, before, "drawn again from the kept glyph geometry");
|
||||
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
|
||||
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
|
||||
assert_ne!(before, after, "drawn in the font that arrived");
|
||||
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
|
||||
}
|
||||
|
||||
/// Type with a geometry effect in a symbol's art redraws in a font that becomes available: the art
|
||||
/// kept for the instance is placed again when the fonts change.
|
||||
#[test]
|
||||
|
||||
@@ -67,13 +67,37 @@ fn dim_images_fades_only_the_images() {
|
||||
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
|
||||
}
|
||||
|
||||
/// #1683: a template layer dims its images by its Dim Images percentage and nothing else: with
|
||||
/// the option off they show at full strength, and the layer's paths are never faded.
|
||||
#[test]
|
||||
fn a_template_layer_dims_its_images_by_dim_images() {
|
||||
let template = |dim: Option<u8>| {
|
||||
let d = doc(|k| {
|
||||
if let NodeKind::Layer { template, dim_images, .. } = k {
|
||||
(*template, *dim_images) = (true, dim);
|
||||
}
|
||||
});
|
||||
let img = render(&d, false);
|
||||
(img.pixel(65, 25), img.pixel(25, 25))
|
||||
};
|
||||
let red = [255, 0, 0, 255];
|
||||
assert_eq!(template(None), (red, red), "Dim Images off: as it is");
|
||||
let (image, path) = template(Some(50));
|
||||
assert!(image[0] == 255 && (120..=135).contains(&image[1]) && image[1] == image[2], "half way to white: {image:?}");
|
||||
assert_eq!(path, red, "vector art stays");
|
||||
let (image, _) = template(Some(20));
|
||||
assert!((198..=210).contains(&image[1]) && image[1] == image[2], "a fifth of its strength: {image:?}");
|
||||
assert_eq!(template(Some(100)), (red, red));
|
||||
}
|
||||
|
||||
/// In Outline view a template layer still draws in Preview, dimmed, so its picture can be traced;
|
||||
/// the layer above it draws in outline (#1486).
|
||||
#[test]
|
||||
fn a_template_layer_stays_in_preview_in_outline_view() {
|
||||
let mut d = doc(|k| {
|
||||
if let NodeKind::Layer { template, preview, .. } = k {
|
||||
*template = true;
|
||||
if let NodeKind::Layer { template, preview, dim_images, .. } = k {
|
||||
// As Layer Options makes a template: its images dimmed to 50 %.
|
||||
(*template, *dim_images) = (true, Some(50));
|
||||
// Preview off doesn't put a template in outline either.
|
||||
*preview = false;
|
||||
}
|
||||
|
||||
@@ -182,6 +182,122 @@ fn swept_type_entries_give_back_their_bytes() {
|
||||
assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0));
|
||||
}
|
||||
|
||||
/// [`text_doc`] of three type objects reading `word`: one at 80% opacity, one in Multiply and one
|
||||
/// with a drop shadow.
|
||||
fn faded_text_doc(word: &str) -> Document {
|
||||
let mut d = text_doc(3, word);
|
||||
let ids: Vec<NodeId> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||||
d.node_mut(ids[0]).unwrap().opacity = 0.8;
|
||||
d.node_mut(ids[1]).unwrap().blend = vectorcraft_color::BlendMode::Multiply;
|
||||
d.node_mut(ids[2]).unwrap().appearance.effects.push(drop_shadow());
|
||||
d
|
||||
}
|
||||
|
||||
/// The glyph geometry `r` keeps for each type object of `d`: under its node, or under the art
|
||||
/// drawn for it when it has effects.
|
||||
fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec<Option<Arc<TextGeom>>> {
|
||||
let stamp = r.stamp;
|
||||
text_keys(d)
|
||||
.into_iter()
|
||||
.map(|k| {
|
||||
let k = r.fx_arts.get(&k, stamp).map_or(k, |e| Arc::as_ptr(&e.1) as usize);
|
||||
r.texts.get(&k, stamp).map(|(_, g)| g.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the
|
||||
/// renderer lays all type out again when the fonts change.
|
||||
fn fonts_unchanged<T>(mut f: impl FnMut() -> T) -> T {
|
||||
loop {
|
||||
let fonts = vectorcraft_text::FontDb::global().generation();
|
||||
let t = f();
|
||||
if vectorcraft_text::FontDb::global().generation() == fonts {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow keeps its glyph geometry between
|
||||
/// frames, as opaque type does: the second frame draws the geometry the first laid out. Frames
|
||||
/// that snap type to pixels keep none, and neither does type art that the effect-art cache has no
|
||||
/// room for.
|
||||
#[test]
|
||||
fn translucent_blended_and_shadowed_type_keeps_its_glyph_geometry_between_frames() {
|
||||
let d = faded_text_doc("Kept");
|
||||
let opts = RenderOptions::default();
|
||||
let (first, kept, entries, second, again) = fonts_unchanged(|| {
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels;
|
||||
let (kept, entries) = (kept_glyphs(&mut r, &d), r.texts.len());
|
||||
let second = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels;
|
||||
(first, kept, entries, second, kept_glyphs(&mut r, &d))
|
||||
});
|
||||
assert!(kept.iter().all(Option::is_some) && entries == 3, "{entries} entries");
|
||||
assert!(second == first);
|
||||
let same = kept.iter().zip(&again).all(|pair| matches!(pair, (Some(a), Some(b)) if Arc::ptr_eq(a, b)));
|
||||
assert!(same, "the second frame draws the geometry the first laid out");
|
||||
// Type anti-aliasing snaps the glyphs to pixels.
|
||||
let mut r = Renderer::new();
|
||||
r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions { anti_alias: AntiAlias::Type, ..Default::default() });
|
||||
assert!(r.texts.is_empty());
|
||||
// With no room for effect art, the shadowed type's art is made in every frame.
|
||||
let frames = fonts_unchanged(|| {
|
||||
let mut r = Renderer::new();
|
||||
r.fx_arts = FrameCache::new(0, 0);
|
||||
(0..2).map(|_| (r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels == first, r.texts.len())).collect::<Vec<_>>()
|
||||
});
|
||||
assert_eq!(frames, [(true, 2), (true, 2)]);
|
||||
}
|
||||
|
||||
/// Editing the text of type at 80% opacity, in Multiply or with a drop shadow lays it out again:
|
||||
/// the next frame draws what a new renderer draws.
|
||||
#[test]
|
||||
fn edited_translucent_blended_and_shadowed_type_draws_as_new() {
|
||||
for i in 0..3 {
|
||||
let mut d = faded_text_doc("Kept");
|
||||
let id = d.layers[0].children().unwrap()[i].id;
|
||||
let opts = RenderOptions::default();
|
||||
draws_as_new(&mut d, &opts, &opts, |d| {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].text = "Edit".into();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Type showing an inline graphic, at 80% opacity, in Multiply, with a drop shadow and opaque:
|
||||
/// when the symbol's art changes size and the document's inline graphics are not resolved again,
|
||||
/// the next frame lays the type out with the new size, as a new renderer does.
|
||||
#[test]
|
||||
fn type_with_an_inline_graphic_draws_its_symbols_new_size() {
|
||||
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
|
||||
for i in 0..4 {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let style = CharStyle { size: 24.0, ..Default::default() };
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone());
|
||||
t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone()));
|
||||
t.runs.push(TextRun::new("B", style));
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
match i {
|
||||
0 => n.opacity = 0.8,
|
||||
1 => n.blend = vectorcraft_color::BlendMode::Multiply,
|
||||
2 => n.appearance.effects.push(drop_shadow()),
|
||||
_ => {}
|
||||
}
|
||||
let mut d = with_art(d, vec![n]);
|
||||
d.resolve_inline_art();
|
||||
// `S` becomes a wide red bar: the inline graphic grows, and the type after it moves.
|
||||
edit_draws_as_new(d, |d| {
|
||||
let bar = vectorcraft_geom::shapes::rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0));
|
||||
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol's art inside the 20 × 20 square around the origin: a group of a square filled blue
|
||||
/// with a 2 pt black stroke and a black line.
|
||||
fn symbol_art(d: &mut Document) -> Node {
|
||||
@@ -824,6 +940,63 @@ fn rasters_draw_pattern_edits() {
|
||||
});
|
||||
}
|
||||
|
||||
/// #1723: a pattern's tile is drawn again when what its art shows changes while the art itself
|
||||
/// doesn't: a symbol an instance in it shows, another pattern it is painted with, or a brush it is
|
||||
/// stroked with. So is a raster effect on an object painted with such a pattern.
|
||||
#[test]
|
||||
fn pattern_tiles_draw_edits_to_what_their_art_shows() {
|
||||
let _fonts = fonts_steady();
|
||||
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||
use vectorcraft_geom::shapes::rectangle;
|
||||
let tile = |name: &str, art: Node| {
|
||||
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
def
|
||||
};
|
||||
// Pattern `Stamps` shows symbol `S`; pattern `Outer` is a square painted with pattern `Checks`.
|
||||
let doc = |pattern: &str, effects: Vec<vectorcraft_doc::Effect>| {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((10.0, 10.0)) });
|
||||
d.patterns.push(tile("Stamps", shown));
|
||||
d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let square = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), pattern_paint("Checks"), vec![]);
|
||||
d.patterns.push(tile("Outer", square));
|
||||
let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint(pattern), effects);
|
||||
with_art(d, vec![painted])
|
||||
};
|
||||
let new_symbol = |d: &mut Document| {
|
||||
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let bar = Node::path(d.alloc_id(), rectangle(Rect::new(-8.0, -2.0, 8.0, 2.0)), red);
|
||||
d.symbols[0].art = Arc::new(bar);
|
||||
};
|
||||
let new_checks = |d: &mut Document| {
|
||||
if let Some(def) = d.pattern_mut("Checks") {
|
||||
def.art = checks(Color::rgb(0.0, 0.0, 1.0)).art;
|
||||
}
|
||||
};
|
||||
let blur = || vec![effect("blur.gaussian", serde_json::json!({"radius": 2.0}))];
|
||||
edit_draws_as_new(doc("Stamps", vec![]), new_symbol);
|
||||
edit_draws_as_new(doc("Stamps", blur()), new_symbol);
|
||||
edit_draws_as_new(doc("Outer", vec![]), new_checks);
|
||||
edit_draws_as_new(doc("Outer", blur()), new_checks);
|
||||
// Pattern `Brushed` is a line stroked with a calligraphic brush; the brush gets wider.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let mut stroke = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
|
||||
let brushed = Node::path(d.alloc_id(), vectorcraft_geom::shapes::line((2.0, 10.0).into(), (18.0, 10.0).into()), stroke);
|
||||
d.patterns.push(tile("Brushed", brushed));
|
||||
let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint("Brushed"), vec![]);
|
||||
edit_draws_as_new(with_art(d, vec![painted]), |d| {
|
||||
let mut lib = vectorcraft_brush::library(d);
|
||||
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind {
|
||||
c.size = 12.0;
|
||||
}
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
});
|
||||
}
|
||||
|
||||
/// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop
|
||||
/// shadow around an instance of the other symbol), when a brush definition changes (a brushed
|
||||
/// stroke's drop shadow), and when an image's pixels change under the same key, as when a linked
|
||||
@@ -977,6 +1150,149 @@ fn rasters_follow_the_view_options() {
|
||||
draws_as_new(&mut d, &isolated, &on_white, unchanged);
|
||||
}
|
||||
|
||||
/// A document for the proof previews: a cyan square, a magenta circle over it whose black stroke
|
||||
/// overprints, a white square over the cyan one that overprints, a blurred rectangle in spot color
|
||||
/// `Spot`, and an instance of symbol `S`, a group holding a yellow square.
|
||||
fn proof_doc() -> Document {
|
||||
use vectorcraft_geom::shapes::{ellipse, rectangle};
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0);
|
||||
d.swatches.push(vectorcraft_color::Swatch { name: "Spot".into(), paint: Paint::solid(orange), global: true, spot: true });
|
||||
let cyan = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), vec![]);
|
||||
let black = Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
let ring = vectorcraft_doc::Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), black, 4.0);
|
||||
let mut circle = Node::path(d.alloc_id(), ellipse(Rect::new(40.0, 20.0, 100.0, 80.0)), ring);
|
||||
circle.appearance.stroke_mut().unwrap().overprint = true;
|
||||
let mut white = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 40.0, 50.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 0.0)), vec![]);
|
||||
white.appearance.fill_mut().unwrap().overprint = true;
|
||||
let spot = Paint::Solid { color: orange, swatch: Some("Spot".into()), tint: 1.0 };
|
||||
let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0}));
|
||||
let spot = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 150.0, 50.0)), spot, vec![blur]);
|
||||
let yellow = filled(&mut d, rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)), vec![]);
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(yellow)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
|
||||
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((175.0, 75.0)) });
|
||||
with_art(d, vec![cyan, circle, white, spot, instance])
|
||||
}
|
||||
|
||||
/// Overprint Preview, and Separations Preview showing `plates`, which turns Overprint Preview on
|
||||
/// too (as the canvas draws them).
|
||||
fn proof_views(plates: &[&str]) -> (RenderOptions, RenderOptions) {
|
||||
let overprint = RenderOptions { overprint_preview: true, ..Default::default() };
|
||||
let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() };
|
||||
(overprint.clone(), RenderOptions { proof: Some(proof), ..overprint })
|
||||
}
|
||||
|
||||
/// [`proof_views`] with every plate of [`proof_doc`].
|
||||
fn every_plate() -> (RenderOptions, RenderOptions) {
|
||||
proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Spot"])
|
||||
}
|
||||
|
||||
/// With Overprint Preview or Separations Preview on, a second frame of an unchanged document draws
|
||||
/// the art the first prepared for the preview: it makes no geometry entries. After an object
|
||||
/// moves, a frame makes them for the object and its layer only. A frame drawn with neither preview
|
||||
/// lets go of the art, and each frame drawn with a preview draws what a new renderer draws.
|
||||
#[test]
|
||||
fn proof_previews_keep_their_art_between_frames() {
|
||||
let _fonts = fonts_steady();
|
||||
let d = proof_doc();
|
||||
let (overprint, separations) = every_plate();
|
||||
let frame = |r: &mut Renderer, d: &Document, opts: &RenderOptions| r.render(d, 200, 100, Affine::IDENTITY, opts).pixels;
|
||||
let geometry = |r: &Renderer| r.geom.keys().copied().collect::<std::collections::HashSet<_>>();
|
||||
for opts in [&overprint, &separations] {
|
||||
let mut r = Renderer::new();
|
||||
let first = frame(&mut r, &d, opts);
|
||||
let kept = geometry(&r);
|
||||
assert!(frame(&mut r, &d, opts) == first);
|
||||
assert_eq!(geometry(&r), kept, "the second frame makes no geometry entries");
|
||||
// The cyan square moves 5 pt right.
|
||||
let mut moved = d.clone();
|
||||
let square = moved.layers[0].children().unwrap()[0].id;
|
||||
moved.node_mut(square).unwrap().transform(Affine::translate((5.0, 0.0)), false);
|
||||
assert!(frame(&mut r, &moved, opts) == frame(&mut Renderer::new(), &moved, opts));
|
||||
assert_eq!(geometry(&r).difference(&kept).count(), 2, "the square and its layer");
|
||||
frame(&mut r, &moved, &RenderOptions::default());
|
||||
assert!(r.proof_art.is_none(), "drawn without the previews, the renderer keeps no art for them");
|
||||
}
|
||||
}
|
||||
|
||||
/// With Overprint Preview or Separations Preview on, a renderer draws what a new renderer draws
|
||||
/// after a change to the art or to what the previews read: an object recolored, Discard White
|
||||
/// Overprint turned off, a symbol's art recolored (its instance shows the plates of the new art),
|
||||
/// the spot color redefined, a spot color in a color group redefined, the Spot Colors option set
|
||||
/// to CMYK values, a plate hidden, Simulate Paper Color, another proof profile, and one preview
|
||||
/// after the other.
|
||||
#[test]
|
||||
fn proof_previews_draw_changes_as_a_new_renderer_does() {
|
||||
let _fonts = fonts_steady();
|
||||
let (overprint, separations) = every_plate();
|
||||
let unchanged = |_: &mut Document| {};
|
||||
let recolor_square = |d: &mut Document| {
|
||||
let square = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(square).unwrap().appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)));
|
||||
};
|
||||
let recolor_symbol = |d: &mut Document| {
|
||||
let mut art = (*d.symbols[0].art).clone();
|
||||
if let Some(square) = art.children_mut().and_then(|c| c.first_mut()) {
|
||||
Arc::make_mut(square).appearance.set_fill(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)));
|
||||
}
|
||||
d.symbols[0].art = Arc::new(art);
|
||||
};
|
||||
for opts in [&overprint, &separations] {
|
||||
draws_as_new(&mut proof_doc(), opts, opts, recolor_square);
|
||||
draws_as_new(&mut proof_doc(), opts, opts, |d| d.setup.discard_white_overprint = false);
|
||||
}
|
||||
draws_as_new(&mut proof_doc(), &separations, &separations, recolor_symbol);
|
||||
draws_as_new(&mut proof_doc(), &separations, &separations, |d| {
|
||||
if let Some(s) = d.swatch_mut("Spot") {
|
||||
s.paint = Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0));
|
||||
}
|
||||
});
|
||||
// A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate.
|
||||
let lab_doc = || {
|
||||
let mut d = proof_doc();
|
||||
let lab = Color::lab(50.0, 60.0, 40.0);
|
||||
let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true };
|
||||
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] });
|
||||
let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 };
|
||||
let square = filled(&mut d, vectorcraft_geom::shapes::rectangle(Rect::new(115.0, 60.0, 155.0, 95.0)), fill, vec![]);
|
||||
with_art(d, vec![square])
|
||||
};
|
||||
let lab_plate = proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Lab Spot"]).1;
|
||||
draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| {
|
||||
if let Some(s) = d.swatch_mut("Lab Spot") {
|
||||
s.paint = Paint::solid(Color::lab(70.0, -40.0, 30.0));
|
||||
}
|
||||
});
|
||||
draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| d.spot_use_lab = false);
|
||||
let hidden = proof_views(&["Magenta", "Yellow", "Black", "Spot"]).1;
|
||||
draws_as_new(&mut proof_doc(), &separations, &hidden, unchanged);
|
||||
let with = |f: fn(&mut proof::ProofSetup)| {
|
||||
let mut opts = separations.clone();
|
||||
if let Some(p) = &mut opts.proof {
|
||||
f(p);
|
||||
}
|
||||
opts
|
||||
};
|
||||
draws_as_new(&mut proof_doc(), &separations, &with(|p| p.simulate_paper = true), unchanged);
|
||||
let device = with(|p| p.target = proof::ProofTarget::Cmyk(vectorcraft_color::cms::DEVICE_CMYK.into()));
|
||||
draws_as_new(&mut proof_doc(), &separations, &device, unchanged);
|
||||
let cyan = proof_views(&["Cyan"]).1;
|
||||
draws_as_new(&mut proof_doc(), &overprint, &cyan, unchanged);
|
||||
draws_as_new(&mut proof_doc(), &cyan, &overprint, unchanged);
|
||||
// On the Yellow plate the instance is solid while its art is yellow, and blank once it is cyan.
|
||||
let yellow = proof_views(&["Yellow"]).1;
|
||||
let mut d = proof_doc();
|
||||
let mut r = Renderer::new();
|
||||
let instance = |r: &mut Renderer, d: &Document| {
|
||||
let pixels = r.render(d, 200, 100, Affine::IDENTITY, &yellow).pixels;
|
||||
Rendered { width: 200, height: 100, pixels }.pixel(175, 75)
|
||||
};
|
||||
assert_eq!(instance(&mut r, &d), [0, 0, 0, 255]);
|
||||
recolor_symbol(&mut d);
|
||||
assert_eq!(instance(&mut r, &d), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
/// A thumbnail of an object under a filter that treats color channels apart (Color Halftone) is
|
||||
/// made again when the document's color mode changes: CMYK documents screen ink channels, RGB
|
||||
/// documents light ones.
|
||||
|
||||
@@ -917,7 +917,7 @@ impl Writer<'_> {
|
||||
let doc = self.doc;
|
||||
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
|
||||
match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
|
||||
Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(b, bp, st)),
|
||||
Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)),
|
||||
None => StrokePlan::Written(stroke::for_writer(bp, st)),
|
||||
}
|
||||
}
|
||||
|
||||
+34
-14
@@ -18,10 +18,13 @@ use crate::{ImportOptions, SvgError, fnv1a};
|
||||
use css::XNode;
|
||||
use text::TextSlots;
|
||||
|
||||
pub use depth::MAX_ELEMENT_DEPTH;
|
||||
pub(crate) use depth::xml_too_deep;
|
||||
pub(crate) use fx::BLEND;
|
||||
|
||||
mod css;
|
||||
mod css_transform;
|
||||
mod depth;
|
||||
mod files;
|
||||
mod fx;
|
||||
mod spread;
|
||||
@@ -29,11 +32,30 @@ mod symbol_units;
|
||||
mod text;
|
||||
mod vector_effect;
|
||||
|
||||
/// The XML of `svg`.
|
||||
fn parse(svg: &str) -> Result<roxmltree::Document<'_>, SvgError> {
|
||||
roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })
|
||||
.map_err(|e| SvgError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
|
||||
let (svg, found) = prepass(svg, opts);
|
||||
// Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by
|
||||
// recursion.
|
||||
let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH);
|
||||
if depth::xml_too_deep(svg) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let xml = parse(svg)?;
|
||||
if depth::too_deep(&xml) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let (svg, found) = prepass(svg, &xml, opts);
|
||||
// The prepass's edits are in the text: the XML is read again only when it made any.
|
||||
let xml = match &svg {
|
||||
Cow::Borrowed(_) => xml,
|
||||
Cow::Owned(edited) => parse(edited)?,
|
||||
};
|
||||
let svg = svg.as_ref();
|
||||
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })
|
||||
.map_err(|e| SvgError::Parse(e.to_string()))?;
|
||||
let units = RootUnits::of(xml.root_element());
|
||||
let root_name = root_layer_name(xml.root_element());
|
||||
let mut warnings = found.warnings;
|
||||
@@ -165,6 +187,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
promote_sublayers(Arc::make_mut(l), &im.sublayer_groups);
|
||||
}
|
||||
}
|
||||
im.doc.limit_depth_noting(&mut im.warnings);
|
||||
Ok((im.doc, im.warnings))
|
||||
}
|
||||
|
||||
@@ -554,7 +577,7 @@ impl Edits {
|
||||
/// * linked files are read ([`files`]);
|
||||
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]);
|
||||
/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]).
|
||||
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
let mut found = Found::default();
|
||||
// (`:` for prefixed elements such as `<svg:image>`.)
|
||||
let css_transform = has_css_transform(svg);
|
||||
@@ -565,9 +588,6 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
{
|
||||
return (svg.into(), found);
|
||||
}
|
||||
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
||||
return (svg.into(), found);
|
||||
};
|
||||
let mut edits = Edits::default();
|
||||
let elements = || xml.descendants().filter(|n| n.is_element());
|
||||
for a in elements().filter(|n| n.tag_name().name() == "a") {
|
||||
@@ -592,24 +612,24 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
||||
}
|
||||
let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING));
|
||||
if display || non_scaling || not {
|
||||
let css = css::Styles::new(&xml);
|
||||
let css = css::Styles::new(xml);
|
||||
// First: an undisplayed object shown for usvg is shown after these.
|
||||
if not && css.has_not_rules() {
|
||||
not_rules(svg, &xml, &css, &mut edits);
|
||||
not_rules(svg, xml, &css, &mut edits);
|
||||
}
|
||||
if display {
|
||||
hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden);
|
||||
hidden_objects(svg, xml, &css, &mut edits, &mut found.hidden);
|
||||
}
|
||||
if non_scaling {
|
||||
vector_effect::find(svg, &xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings);
|
||||
vector_effect::find(svg, xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings);
|
||||
}
|
||||
}
|
||||
found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings);
|
||||
found.files = files::Files::read(svg, xml, opts, &mut edits, &mut found.warnings);
|
||||
if !symbols.is_empty() && svg.contains('%') {
|
||||
edits.list.extend(symbol_units::rewrites(&xml));
|
||||
edits.list.extend(symbol_units::rewrites(xml));
|
||||
}
|
||||
if css_transform {
|
||||
edits.list.extend(css_transform::rewrites(svg, &xml));
|
||||
edits.list.extend(css_transform::rewrites(svg, xml));
|
||||
}
|
||||
(edits.apply(svg), found)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
|
||||
|
||||
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
|
||||
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
|
||||
fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
|
||||
pub(super) fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
|
||||
s.split(';')
|
||||
.filter_map(|d| {
|
||||
let (k, v) = d.split_once(':')?;
|
||||
@@ -214,7 +214,13 @@ impl Selector {
|
||||
}
|
||||
|
||||
fn matches(&self, n: XNode) -> bool {
|
||||
fn go(parts: &[(Compound, bool)], n: XNode) -> bool {
|
||||
// The result of matching a selector suffix from a node depends only on the suffix and
|
||||
// the node, so memoize it: without this, every descendant step retries every matching
|
||||
// ancestor and a selector whose leftmost compound never matches makes the search
|
||||
// enumerate ancestor combinations (#1703). A suffix of `parts` is identified by its
|
||||
// length, so (suffix length, node id) is a complete key and the visited states are
|
||||
// bounded by parts × ancestors.
|
||||
fn go(parts: &[(Compound, bool)], n: XNode, memo: &mut HashMap<(usize, roxmltree::NodeId), bool>) -> bool {
|
||||
let Some(((c, child), rest)) = parts.split_first() else { return true };
|
||||
if !c.matches(n) {
|
||||
return false;
|
||||
@@ -222,13 +228,19 @@ impl Selector {
|
||||
if rest.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if *child {
|
||||
n.parent_element().is_some_and(|p| go(rest, p))
|
||||
} else {
|
||||
n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a))
|
||||
let key = (rest.len(), n.id());
|
||||
if let Some(&hit) = memo.get(&key) {
|
||||
return hit;
|
||||
}
|
||||
let hit = if *child {
|
||||
n.parent_element().is_some_and(|p| go(rest, p, memo))
|
||||
} else {
|
||||
n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a, memo))
|
||||
};
|
||||
memo.insert(key, hit);
|
||||
hit
|
||||
}
|
||||
go(&self.parts, n)
|
||||
go(&self.parts, n, &mut HashMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,8 +383,13 @@ impl Styles {
|
||||
Self { rules, index, cache: RefCell::default() }
|
||||
}
|
||||
|
||||
/// Do the document's style sheets have any rules?
|
||||
pub(super) fn has_rules(&self) -> bool {
|
||||
!self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// Properties set on `n` by rules and its `style` attribute, after the cascade.
|
||||
fn declared(&self, n: XNode) -> Rc<HashMap<String, String>> {
|
||||
pub(super) fn declared(&self, n: XNode) -> Rc<HashMap<String, String>> {
|
||||
if let Some(m) = self.cache.borrow().get(&n.id()) {
|
||||
return m.clone();
|
||||
}
|
||||
@@ -484,6 +501,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_matching_does_not_backtrack_combinatorially() {
|
||||
// Many nested groups share class `a` and the first rule stacks eight descendant
|
||||
// `.a` compounds under a leftmost class that matches nothing: an unmemoized
|
||||
// matcher enumerates ancestor combinations for it and effectively never
|
||||
// finishes (#1703). With suffix states memoized this returns at once, and the
|
||||
// matching descendant and mixed child/descendant rules still apply.
|
||||
let depth = 48;
|
||||
let mut svg = String::from(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>
|
||||
.missing .a .a .a .a .a .a .a .a text { fill: #111 }
|
||||
.a .a .a text { fill: #222 }
|
||||
.a > .a text { stroke: blue }
|
||||
</style>"#,
|
||||
);
|
||||
for _ in 0..depth {
|
||||
svg.push_str(r#"<g class="a">"#);
|
||||
}
|
||||
svg.push_str(r#"<text id="t">x</text>"#);
|
||||
for _ in 0..depth {
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
with_doc(&svg, |doc, st| {
|
||||
let t = by_id(doc, "t");
|
||||
assert_eq!(st.own(t, "fill").as_deref(), Some("#222"), "descendant rule matches, the .missing one does not");
|
||||
assert_eq!(st.own(t, "stroke").as_deref(), Some("blue"), "mixed child/descendant rule matches");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_attributes_lose_to_rules() {
|
||||
with_doc(
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! How deep an SVG's elements nest, found in the XML before usvg runs: usvg converts the tree by
|
||||
//! recursion, a few stack frames an element, so a file nested deeply enough overflows the stack
|
||||
//! (an abort, not an error). Such a file is refused: its nesting can't be merged before usvg has
|
||||
//! read it, and usvg is what can't read it.
|
||||
//!
|
||||
//! What counts is the tree usvg walks, not the XML alone: a `<use>` holds a copy of what it
|
||||
//! refers to, and a clip path, mask, pattern, marker or filter is converted where it is used
|
||||
//! (`clip-path: url(#a)` on an element of a clip path makes a chain as deep as there are clip
|
||||
//! paths). So each element's depth is the deepest of what it holds and what it refers to.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
use usvg::roxmltree;
|
||||
|
||||
use super::css::{Styles, XNode, parse_decls};
|
||||
|
||||
/// The deepest an SVG's elements may nest, references followed. Far beyond what drawing apps
|
||||
/// write, and deeper than a document's objects may nest ([`vectorcraft_doc::MAX_DEPTH`]: wrappers
|
||||
/// that hold one object aren't kept, and what is still deeper is merged into the group around
|
||||
/// it). usvg uses about 15 kB of stack for each level: the importer runs on a thread with 16 MB
|
||||
/// ([`vectorcraft_doc::depth::with_deep_stack`]), except on the web, where there is the one
|
||||
/// stack: [`WEB_DEPTH`] there.
|
||||
pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { WEB_DEPTH } else { 256 };
|
||||
|
||||
/// The stack of the web build: rustc links `wasm32-unknown-unknown` with
|
||||
/// `-z stack-size=1048576` (`rustc --print link-args` shows it), and the web app gives the linker
|
||||
/// no arguments of its own (`apps/vectorcraft-web`: `Trunk.toml`, `index.html`, no rustflags).
|
||||
const WEB_STACK: usize = 1 << 20;
|
||||
|
||||
/// The stack usvg uses for each level of elements: about 70 levels overflowed the 1 MB main
|
||||
/// thread of Windows. Measured natively, never in a browser.
|
||||
const LEVEL_STACK: usize = 15 << 10;
|
||||
|
||||
/// The deepest elements may nest on the web: half its stack at usvg's cost a level, the other
|
||||
/// half left for what is on the stack when a file is opened and for the cost being a native
|
||||
/// measure. 1,048,576 / (2 × 15,360) = 34.
|
||||
const WEB_DEPTH: usize = WEB_STACK / (2 * LEVEL_STACK);
|
||||
|
||||
/// Properties whose value an element's descendants use too: the paint server they name is
|
||||
/// converted at each descendant, under everything between.
|
||||
const INHERITED: [&str; 6] = ["fill", "stroke", "marker", "marker-start", "marker-mid", "marker-end"];
|
||||
|
||||
/// The ids of the `url(#id)` references in a property value.
|
||||
fn urls(value: &str) -> impl Iterator<Item = &str> {
|
||||
value.split("url(").skip(1).filter_map(|rest| {
|
||||
let id = rest.trim_start().trim_start_matches(['"', '\'']).strip_prefix('#')?;
|
||||
id.split([')', '"', '\'']).next().map(str::trim).filter(|id| !id.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
/// The elements `n` refers to, each with whether `n`'s descendants use it too.
|
||||
fn references<'a, 'i>(n: XNode<'a, 'i>, ids: &HashMap<&str, XNode<'a, 'i>>, css: &Styles, out: &mut Vec<(XNode<'a, 'i>, bool)>) {
|
||||
let add = |out: &mut Vec<(XNode<'a, 'i>, bool)>, name: &str, value: &str| {
|
||||
let inherited = INHERITED.contains(&name);
|
||||
out.extend(urls(value).filter_map(|id| ids.get(id)).map(|t| (*t, inherited)));
|
||||
};
|
||||
for a in n.attributes() {
|
||||
match a.name() {
|
||||
"href" => out.extend(a.value().trim().strip_prefix('#').and_then(|id| ids.get(id)).map(|t| (*t, false))),
|
||||
// Read with the style sheets' rules below.
|
||||
"style" if css.has_rules() => {}
|
||||
"style" => parse_decls(a.value()).iter().for_each(|(k, v, _)| add(out, k, v)),
|
||||
name => add(out, name, a.value()),
|
||||
}
|
||||
}
|
||||
if css.has_rules() {
|
||||
css.declared(n).iter().for_each(|(k, v)| add(out, k, v));
|
||||
}
|
||||
}
|
||||
|
||||
/// Do the elements of the XML text `svg` nest deeper than [`MAX_ELEMENT_DEPTH`]? Read from the
|
||||
/// text, before any XML reader: roxmltree (ours and usvg's) reads elements by recursion too. Tags
|
||||
/// are counted as the text has them; markup in the values of a document type's entities counts
|
||||
/// ten times (how deep entities may refer to one another), on top of the deepest element.
|
||||
pub(crate) fn xml_too_deep(svg: &str) -> bool {
|
||||
let b = svg.as_bytes();
|
||||
let find =
|
||||
|from: usize, what: &[u8]| b.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len());
|
||||
// The end of the tag starting at `from` (past its `>`), and whether it closes itself.
|
||||
let tag_end = |from: usize| {
|
||||
let mut quote = None;
|
||||
for (i, c) in b.iter().enumerate().skip(from) {
|
||||
match (quote, c) {
|
||||
(None, b'"' | b'\'') => quote = Some(*c),
|
||||
(Some(q), c) if q == *c => quote = None,
|
||||
(None, b'>') => return (i + 1, b.get(i.wrapping_sub(1)) == Some(&b'/')),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(b.len(), false)
|
||||
};
|
||||
// (now, deepest) of the elements, and of the markup inside the document type.
|
||||
let (mut elements, mut entities) = ((0usize, 0usize), (0usize, 0usize));
|
||||
let mut doctype_end = 0;
|
||||
let mut i = 0;
|
||||
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|c| *c == b'<')).map(|at| i + at) {
|
||||
let rest = b.get(at..).unwrap_or_default();
|
||||
let depth = if at < doctype_end { &mut entities } else { &mut elements };
|
||||
i = if rest.starts_with(b"<!--") {
|
||||
find(at, b"-->").unwrap_or(b.len())
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
find(at, b"]]>").unwrap_or(b.len())
|
||||
} else if rest.starts_with(b"<?") {
|
||||
find(at, b"?>").unwrap_or(b.len())
|
||||
} else if rest.starts_with(b"<!DOCTYPE") {
|
||||
// Up to the `>` that ends it: after its internal subset (`[` … `]`) when it has one.
|
||||
let plain = tag_end(at).0;
|
||||
let subset = b.get(at..plain).is_some_and(|head| head.contains(&b'['));
|
||||
doctype_end = if subset { find(at, b"]>").unwrap_or(b.len()) } else { plain };
|
||||
at + 2
|
||||
} else if rest.starts_with(b"<!") {
|
||||
// A declaration of the document type: the markup in its value is counted as it comes.
|
||||
at + 2
|
||||
} else if rest.starts_with(b"</") {
|
||||
depth.0 = depth.0.saturating_sub(1);
|
||||
tag_end(at).0
|
||||
} else if at < doctype_end {
|
||||
// In an entity's value, itself in quotes: up to the tag's `>`.
|
||||
let end = find(at, b">").unwrap_or(b.len());
|
||||
depth.1 = depth.1.max(depth.0 + 1);
|
||||
depth.0 += usize::from(b.get(end.wrapping_sub(2)) != Some(&b'/'));
|
||||
end
|
||||
} else {
|
||||
let (end, empty) = tag_end(at);
|
||||
depth.1 = depth.1.max(depth.0 + 1);
|
||||
depth.0 += usize::from(!empty);
|
||||
end
|
||||
};
|
||||
if elements.1 + 10 * entities.1 > MAX_ELEMENT_DEPTH {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Does anything in `xml` nest deeper than [`MAX_ELEMENT_DEPTH`], references followed? Without
|
||||
/// recursion.
|
||||
pub(super) fn too_deep(xml: &roxmltree::Document) -> bool {
|
||||
let elements = || xml.descendants().filter(|n| n.is_element());
|
||||
let ids: HashMap<&str, XNode> = elements().filter_map(|n| Some((n.attribute("id")?, n))).collect();
|
||||
let css = Styles::new(xml);
|
||||
// How deep each element's own tree goes, itself included; 0 while it is being worked out: a
|
||||
// reference back to it then is a loop, which usvg leaves out.
|
||||
let mut heights: HashMap<roxmltree::NodeId, usize> = HashMap::new();
|
||||
enum Step<'a, 'i> {
|
||||
Enter(XNode<'a, 'i>),
|
||||
Leave(XNode<'a, 'i>, Vec<(XNode<'a, 'i>, bool)>),
|
||||
}
|
||||
let mut stack = vec![Step::Enter(xml.root_element())];
|
||||
while let Some(step) = stack.pop() {
|
||||
match step {
|
||||
Step::Enter(n) => {
|
||||
match heights.entry(n.id()) {
|
||||
Entry::Occupied(_) => continue,
|
||||
Entry::Vacant(v) => v.insert(0),
|
||||
};
|
||||
let mut refs = vec![];
|
||||
references(n, &ids, &css, &mut refs);
|
||||
let targets: Vec<XNode> = refs.iter().map(|(t, _)| *t).collect();
|
||||
stack.push(Step::Leave(n, refs));
|
||||
stack.extend(n.children().filter(|c| c.is_element()).chain(targets).map(Step::Enter));
|
||||
}
|
||||
Step::Leave(n, refs) => {
|
||||
let of = |n: XNode| heights.get(&n.id()).copied().unwrap_or(0);
|
||||
let held = n.children().filter(|c| c.is_element()).map(of).max().unwrap_or(0);
|
||||
let used = |inherited: bool| refs.iter().filter(|(_, i)| *i == inherited).map(|(t, _)| of(*t)).max().unwrap_or(0);
|
||||
let height = 1 + held.max(used(false)) + used(true);
|
||||
if height > MAX_ELEMENT_DEPTH {
|
||||
return true;
|
||||
}
|
||||
heights.insert(n.id(), height);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn deep(svg: &str) -> bool {
|
||||
too_deep(&roxmltree::Document::parse(svg).unwrap())
|
||||
}
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="10" height="10">{body}</svg>"#)
|
||||
}
|
||||
|
||||
/// `n` groups in one another around a rectangle.
|
||||
fn groups(n: usize) -> String {
|
||||
format!("{}<rect width='1' height='1'/>{}", "<g>".repeat(n), "</g>".repeat(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elements_nest_up_to_the_cap() {
|
||||
// The root, the groups and the rectangle.
|
||||
let (at, past) = (svg(&groups(MAX_ELEMENT_DEPTH - 2)), svg(&groups(MAX_ELEMENT_DEPTH - 1)));
|
||||
assert!(!deep(&at) && !xml_too_deep(&at));
|
||||
assert!(deep(&past) && xml_too_deep(&past));
|
||||
assert!(xml_too_deep(&svg(&groups(200_000))), "any depth is measured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_is_measured_as_xml_reads_it() {
|
||||
let many = r#"<g a='>' b="<g>"><rect/></g>"#.repeat(500);
|
||||
assert!(!xml_too_deep(&svg(&many)), "side by side, with `>` and tags in attribute values");
|
||||
let hidden = format!("<!-- {0} --><![CDATA[{0}]]><?pi {0}?>", "<g>".repeat(500));
|
||||
assert!(!xml_too_deep(&svg(&hidden)), "comments, character data and instructions hold no elements");
|
||||
assert!(xml_too_deep(&format!("<svg>{}", "<g>".repeat(MAX_ELEMENT_DEPTH))), "never closed");
|
||||
assert!(!xml_too_deep(&format!("{}<svg/>", "</g>".repeat(2 * MAX_ELEMENT_DEPTH))), "ends without beginnings");
|
||||
// Entities: namespaces as drawing apps declare them, and markup that would be read ten deep.
|
||||
let names = r#"<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY ns_svg "http://www.w3.org/2000/svg"> ]>"#;
|
||||
assert!(!xml_too_deep(&format!("{names}{}", svg(&groups(30)))));
|
||||
let tenth = MAX_ELEMENT_DEPTH / 10 + 1;
|
||||
let bomb = format!("<!DOCTYPE svg [ <!ENTITY a \"{}&a;{}\"> ]>", "<g>".repeat(tenth), "</g>".repeat(tenth));
|
||||
assert!(xml_too_deep(&format!("{bomb}<svg>&a;</svg>")));
|
||||
assert!(!xml_too_deep(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_of_uses_is_as_deep_as_what_it_copies() {
|
||||
let chain = |n: usize| {
|
||||
let defs: String = (0..n).map(|i| format!("<g id='g{i}'><use xlink:href='#g{}'/></g>", i + 1)).collect();
|
||||
svg(&format!("<defs>{defs}<rect id='g{n}' width='1' height='1'/></defs><use href='#g0'/>"))
|
||||
};
|
||||
assert!(!deep(&chain(10)));
|
||||
assert!(deep(&chain(MAX_ELEMENT_DEPTH / 2)), "two elements a link");
|
||||
assert!(deep(&chain(5000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chains_of_clips_masks_and_patterns_count_by_attribute_style_and_style_sheet() {
|
||||
let by_attribute = |n: usize| -> String {
|
||||
(0..n).map(|i| format!("<clipPath id='c{i}' clip-path='url(#c{})'><rect width='1' height='1'/></clipPath>", i + 1)).collect()
|
||||
};
|
||||
let by_style = |n: usize| -> String {
|
||||
(0..n).map(|i| format!("<mask id='c{i}' style='mask: url( \"#c{}\" )'><rect width='1' height='1'/></mask>", i + 1)).collect()
|
||||
};
|
||||
let by_sheet = |n: usize| -> String {
|
||||
let rules: String = (0..n).map(|i| format!(".p{i} {{ fill: url(#c{}) }}", i + 1)).collect();
|
||||
let patterns: String = (0..n)
|
||||
.map(|i| format!("<pattern id='c{i}' width='1' height='1'><g class='p{i}'><rect width='1' height='1'/></g></pattern>"))
|
||||
.collect();
|
||||
format!("<style>{rules}</style>{patterns}")
|
||||
};
|
||||
for chain in [&by_attribute as &dyn Fn(usize) -> String, &by_style, &by_sheet] {
|
||||
let file =
|
||||
|n| svg(&format!("<defs>{}</defs><rect width='5' height='5' fill='url(#c0)' clip-path='url(#c0)' mask='url(#c0)'/>", chain(n)));
|
||||
assert!(!deep(&file(5)));
|
||||
assert!(deep(&file(MAX_ELEMENT_DEPTH)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_paint_servers_used_side_by_side_are_not_a_chain() {
|
||||
// As drawing apps write gradients: a class each, used by objects that aren't inside one another.
|
||||
let rules: String = (0..300).map(|i| format!(".c{i}{{fill:url(#g{i})}}")).collect();
|
||||
let defs: String = (0..300).map(|i| format!("<linearGradient id='g{i}'><stop class='c{i}' offset='0'/></linearGradient>")).collect();
|
||||
let art: String = (0..300).map(|i| format!("<g class='c{i}'><rect class='c{i}' width='1' height='1'/></g>")).collect();
|
||||
assert!(!deep(&svg(&format!("<style>{rules}</style><defs>{defs}</defs>{art}"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_of_references_is_not_deep() {
|
||||
assert!(!deep(&svg("<g id='a'><use href='#b'/></g><g id='b'><use href='#a'/></g><clipPath id='c' clip-path='url(#c)'/>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inherited_paint_is_converted_under_everything_between() {
|
||||
// Neither the pattern nor the art is too deep; the pattern under the art's last object is.
|
||||
let half = MAX_ELEMENT_DEPTH / 2;
|
||||
let pattern = format!("<pattern id='p' width='1' height='1'>{}</pattern>", groups(half));
|
||||
assert!(!deep(&svg(&format!("<defs>{pattern}</defs><g fill='url(#p)'>{}</g>", groups(half / 2)))));
|
||||
assert!(deep(&svg(&format!("<defs>{pattern}</defs><g fill='url(#p)'>{}</g>", groups(half)))));
|
||||
}
|
||||
}
|
||||
@@ -203,12 +203,16 @@ impl Files {
|
||||
ImageHrefResolver {
|
||||
resolve_data: Box::new(move |mime, bytes, opts| {
|
||||
if mime == "image/svg+xml" {
|
||||
if nested_too_deep(&bytes) {
|
||||
return None;
|
||||
}
|
||||
self.note_text(&bytes);
|
||||
}
|
||||
data(mime, bytes, opts)
|
||||
}),
|
||||
resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? {
|
||||
Source::Raster(kind, bytes) => Some(kind(bytes.clone())),
|
||||
Source::Svg(bytes) if nested_too_deep(bytes) => None,
|
||||
Source::Svg(bytes) => {
|
||||
self.note_text(bytes);
|
||||
usvg::Tree::from_data_nested(bytes, opts).ok().map(ImageKind::SVG)
|
||||
@@ -218,6 +222,12 @@ impl Files {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the SVG image `bytes` nested too deep for usvg to read (as the SVG holding it would be)?
|
||||
/// It is then left out, as an image usvg can't read.
|
||||
fn nested_too_deep(bytes: &[u8]) -> bool {
|
||||
crate::text_of(bytes).is_ok_and(|text| super::xml_too_deep(&text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+11
-3
@@ -312,6 +312,9 @@ pub struct LinkedImage {
|
||||
pub enum SvgError {
|
||||
#[error("SVG parse error: {0}")]
|
||||
Parse(String),
|
||||
/// Its elements nest (references followed) deeper than the reader goes.
|
||||
#[error("the SVG's elements are nested more than {0} deep")]
|
||||
TooDeep(usize),
|
||||
}
|
||||
|
||||
/// Export a document as an SVG string (linked images are not returned: see [`export_full`]).
|
||||
@@ -347,7 +350,8 @@ pub struct Editing {
|
||||
|
||||
/// The native document an SVG carries, if any, and whether the SVG was edited since.
|
||||
pub fn editing(svg: &str) -> Option<Editing> {
|
||||
if !svg.contains(EDITING_NS) {
|
||||
// (An SVG nested too deep for the XML reader carries none: it isn't one of ours.)
|
||||
if !svg.contains(EDITING_NS) || import::xml_too_deep(svg) {
|
||||
return None;
|
||||
}
|
||||
let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
|
||||
@@ -500,14 +504,18 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
|
||||
import_with(svg, &ImportOptions::default())
|
||||
}
|
||||
|
||||
pub use import::MAX_ELEMENT_DEPTH;
|
||||
|
||||
/// Import an SVG document reading the files it links to as `opts` says, also returning warnings.
|
||||
pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
|
||||
import::import(svg, opts)
|
||||
// usvg converts the tree by recursion, in large stack frames: the nesting it is given is
|
||||
// capped (`import::depth`), and it gets room for that cap whatever thread asks.
|
||||
vectorcraft_doc::depth::with_deep_stack(|| import::import(svg, opts))
|
||||
}
|
||||
|
||||
/// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or
|
||||
/// `None` when there is no readable file at the (absolute) path.
|
||||
pub type ReadFile<'a> = &'a dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)>;
|
||||
pub type ReadFile<'a> = &'a (dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)> + Sync);
|
||||
|
||||
/// How [`import_with`] reads the files an SVG's `<image>` elements link to.
|
||||
///
|
||||
|
||||
@@ -320,6 +320,10 @@ type Catalog = HashMap<String, CatalogFamily>;
|
||||
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
|
||||
type PostScriptNames = HashMap<String, (String, String)>;
|
||||
|
||||
/// Maps the family and style [`FontDb::canonical`] was asked for to its answer, for names that
|
||||
/// are neither a loaded nor an installed family.
|
||||
type CanonicalNames = HashMap<(String, String), (String, String)>;
|
||||
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
@@ -332,6 +336,9 @@ pub struct FontDb {
|
||||
font_paths: FontPaths,
|
||||
/// The installed fonts' other names (localized, legacy, full, PostScript) by normalized name.
|
||||
aliases: RwLock<HashMap<String, Vec<Alias>>>,
|
||||
/// Keeps [`FontDb::canonical`]'s answers for names other than the loaded and installed
|
||||
/// families until fonts are added or rescanned.
|
||||
canonical_cache: Mutex<CanonicalNames>,
|
||||
/// Set once the font folders have been scanned. Lookups by family name wait for the first
|
||||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -455,6 +462,9 @@ const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// How many answers [`FontDb::canonical`] keeps at most. When it has kept that many, it clears
|
||||
/// them before it keeps the next one.
|
||||
const CANONICAL_CACHE_MAX: usize = 4_096;
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
@@ -717,6 +727,8 @@ struct Alias {
|
||||
impl Alias {
|
||||
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
|
||||
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
|
||||
#[cfg(test)]
|
||||
ALIAS_LISTS.with(|c| c.set(c.get() + 1));
|
||||
let mut v: Vec<(String, Alias)> =
|
||||
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
|
||||
v.extend(
|
||||
@@ -734,6 +746,18 @@ impl Alias {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: counts the faces whose aliases ([`Alias::of`]) were listed on this thread.
|
||||
static ALIAS_LISTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: returns how many faces' aliases were listed on this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn alias_lists() -> u64 {
|
||||
ALIAS_LISTS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// How a requested family and style resolved ([`FontDb::resolve`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontMatch {
|
||||
@@ -1467,6 +1491,7 @@ impl FontDb {
|
||||
postscript: RwLock::new(PostScriptNames::new()),
|
||||
font_paths,
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
canonical_cache: Mutex::new(CanonicalNames::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1509,9 +1534,11 @@ impl FontDb {
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Fonts were added or the catalog changed: cached family lists are stale.
|
||||
/// Fonts were added or the catalog changed: cached family lists and [`FontDb::canonical`]'s
|
||||
/// answers are stale.
|
||||
fn changed(&self) {
|
||||
*self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.generation.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
@@ -1764,6 +1791,24 @@ impl FontDb {
|
||||
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
|
||||
return (family.to_string(), style.to_string());
|
||||
}
|
||||
// The lock is held until the answer is kept, and [`FontDb::changed`] waits for it: an
|
||||
// answer found from the fonts before a change is cleared by that change.
|
||||
let mut cache = self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = (family.to_string(), style.to_string());
|
||||
if let Some(answer) = cache.get(&key) {
|
||||
return answer.clone();
|
||||
}
|
||||
let answer = self.canonical_alias(family, style);
|
||||
if cache.len() >= CANONICAL_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, answer.clone());
|
||||
answer
|
||||
}
|
||||
|
||||
/// Finds [`FontDb::canonical`]'s answer for a name that is neither a loaded nor an installed
|
||||
/// family among the loaded faces' and the installed fonts' other names.
|
||||
fn canonical_alias(&self, family: &str, style: &str) -> (String, String) {
|
||||
let key = norm(family);
|
||||
let mut found: Vec<Alias> =
|
||||
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
|
||||
@@ -2230,6 +2275,19 @@ mod alias_tests {
|
||||
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
|
||||
assert!(db.has_family("SourceSans3-Regular"));
|
||||
}
|
||||
|
||||
/// The database keeps at most [`CANONICAL_CACHE_MAX`] answers, and names asked for past that
|
||||
/// many still get the same answers.
|
||||
#[test]
|
||||
fn the_answers_kept_for_names_are_capped() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for i in 0..=CANONICAL_CACHE_MAX {
|
||||
let name = format!("No Such Family {i}");
|
||||
assert_eq!(db.canonical(&name, "Regular"), (name.clone(), "Regular".into()));
|
||||
}
|
||||
assert!(db.canonical_cache.lock().unwrap().len() <= CANONICAL_CACHE_MAX);
|
||||
assert_eq!(db.canonical("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1113,9 +1113,9 @@ impl Pen<'_> {
|
||||
}
|
||||
|
||||
/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then
|
||||
/// every ½ inch). Right, centre and decimal stops align the text that follows (`rest`, up to the
|
||||
/// next tab) on the stop.
|
||||
fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> f64 {
|
||||
/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal
|
||||
/// stops align the text that follows (`rest`, up to the next tab) on the stop.
|
||||
fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) {
|
||||
use vectorcraft_doc::TabAlign;
|
||||
let rel = x - origin;
|
||||
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect();
|
||||
@@ -1128,14 +1128,52 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
|
||||
};
|
||||
let explicit = tabs.iter().find_map(|t| {
|
||||
let at = target(t.position, t.align, t.align_on);
|
||||
(at > rel + 1e-6).then_some(at)
|
||||
(at > rel + 1e-6).then_some((at, t.leader.as_str()))
|
||||
});
|
||||
let at = explicit.unwrap_or_else(|| {
|
||||
let (at, leader) = explicit.unwrap_or_else(|| {
|
||||
let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0);
|
||||
let base = rel.max(last);
|
||||
((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL
|
||||
(((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL, "")
|
||||
});
|
||||
(at - rel).max(0.0)
|
||||
((at - rel).max(0.0), leader)
|
||||
}
|
||||
|
||||
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
|
||||
/// millions of outlines.
|
||||
const MAX_LEADER_COPIES: f64 = 4096.0;
|
||||
|
||||
/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated
|
||||
/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width
|
||||
/// from `origin` (where tab stops are measured from), so the leaders of lines above one another
|
||||
/// line up; only whole copies are drawn. Characters the font lacks draw nothing.
|
||||
fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let glyphs: Vec<(u32, f64)> = leader
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.map(|c| {
|
||||
let gid = g.face.glyph_for(c);
|
||||
(gid, g.face.advance(gid) * g.sx)
|
||||
})
|
||||
.collect();
|
||||
let width: f64 = glyphs.iter().map(|(_, a)| a).sum();
|
||||
if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) {
|
||||
return out;
|
||||
}
|
||||
let first = ((from - origin) / width - EPS).ceil();
|
||||
let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize;
|
||||
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
|
||||
for k in 0..copies {
|
||||
let mut pen = origin + (first + k as f64) * width;
|
||||
for &(gid, adv) in &glyphs {
|
||||
if gid != 0 {
|
||||
let m = Affine::translate((pen, y)) * local;
|
||||
out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el));
|
||||
}
|
||||
pen += adv;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
|
||||
@@ -1734,8 +1772,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
if j + 1 == trimmed {
|
||||
adv -= end_trim;
|
||||
}
|
||||
let mut leader = "";
|
||||
if g.ch == '\t' {
|
||||
adv = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
|
||||
(adv, leader) = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
|
||||
} else if j < body {
|
||||
if g.is_space() {
|
||||
adv += per_space;
|
||||
@@ -1750,6 +1789,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
}
|
||||
let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical);
|
||||
cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li);
|
||||
if !leader.is_empty() {
|
||||
// The leader belongs to the tab's glyph: the gap stays one character for the
|
||||
// caret, selection and hit testing.
|
||||
let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y);
|
||||
if let Some(tab) = cx.out.glyphs.last_mut() {
|
||||
tab.outline.extend(dots);
|
||||
}
|
||||
}
|
||||
x += adv;
|
||||
if j < trimmed {
|
||||
x_end = x;
|
||||
|
||||
@@ -466,6 +466,33 @@ fn tab_stops_position_text() {
|
||||
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() {
|
||||
use vectorcraft_doc::{TabAlign, TabStop};
|
||||
let mut t = point("A\t1\nAAAAA\t2", style(12.0));
|
||||
t.para.tabs = vec![TabStop { position: 120.0, align: TabAlign::Left, leader: ".".into(), align_on: '.' }];
|
||||
let lay = layout(db(), &t);
|
||||
// The left edges of the dots of each line's tab.
|
||||
let dots = |byte: usize| -> Vec<f64> {
|
||||
let tab = lay.glyphs.iter().find(|g| g.byte == byte).unwrap();
|
||||
let mut xs: Vec<f64> = vec![];
|
||||
for el in tab.outline.elements() {
|
||||
if let kurbo::PathEl::MoveTo(p) = el {
|
||||
xs.push(p.x);
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
xs.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
xs
|
||||
};
|
||||
let (short, long) = (dots(1), dots(9));
|
||||
assert!(short.len() > long.len() && long.len() > 10, "{short:?} {long:?}");
|
||||
// The longer line's dots are the shorter line's last ones, at the same x.
|
||||
let tail = &short[short.len() - long.len()..];
|
||||
assert!(tail.iter().zip(&long).all(|(a, b)| (a - b).abs() < 1e-6), "{tail:?} {long:?}");
|
||||
assert!(short.last().is_some_and(|&x| x < 120.0));
|
||||
}
|
||||
|
||||
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
|
||||
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
|
||||
fn japanese_fonts() -> bool {
|
||||
|
||||
@@ -663,3 +663,91 @@ fn a_full_name_does_not_override_an_installed_family() {
|
||||
let (face, matched) = db.resolve("Alias Script Italic", "Regular").unwrap();
|
||||
assert_eq!((face.family.as_str(), face.style.as_str(), matched), ("Alias Script Italic", "Regular", FontMatch::Exact));
|
||||
}
|
||||
|
||||
/// When type is set in a font that isn't installed, layout searches the loaded faces' names for the
|
||||
/// font's name once until fonts are added.
|
||||
#[test]
|
||||
fn a_missing_fonts_name_is_looked_up_once_until_fonts_are_added() {
|
||||
use crate::fontdb::alias_lists;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
// One lookup lists the names of every loaded face.
|
||||
let before = alias_lists();
|
||||
assert_eq!(db.canonical("Gone Sans", "Bold"), ("Gone Sans".to_string(), "Bold".to_string()));
|
||||
let one_lookup = alias_lists() - before;
|
||||
let style = vectorcraft_doc::CharStyle { font_family: "Gone Sans".into(), ..Default::default() };
|
||||
let t = TextObject::point(kurbo::Point::ZERO, &vec!["Set in a font that is not installed."; 20].join("\n"), style);
|
||||
let before = alias_lists();
|
||||
for _ in 0..2 {
|
||||
assert_eq!(layout(&db, &t).lines.len(), 20);
|
||||
}
|
||||
assert_eq!(alias_lists() - before, one_lookup, "two layouts of 20 paragraphs list the faces' names once");
|
||||
// A font added since has the name as its full name.
|
||||
assert_eq!(db.add_font(named(&[(3, EN, 1, "Found Sans"), (3, EN, 2, "Bold"), (3, EN, 4, "Gone Sans"), (3, EN, 6, "FoundSans-Bold")])), 1);
|
||||
let l = layout(&db, &t);
|
||||
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| db.face_by_id(g.font_id).is_some_and(|f| f.family == "Found Sans")));
|
||||
}
|
||||
|
||||
/// Until an installed font has a name, `canonical` returns the name and the style asked for each
|
||||
/// time; once a rescan finds the font, the name gives the font's family.
|
||||
#[test]
|
||||
fn a_name_asked_for_before_its_font_is_installed_finds_it_after_a_rescan() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-asked-before", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = FontDb::with_font_dirs(vec![dir.clone()]);
|
||||
let answer = |family: &str, style: &str| (family.to_string(), style.to_string());
|
||||
for _ in 0..2 {
|
||||
for style in ["Regular", "Bold"] {
|
||||
assert_eq!(db.canonical("Later Three W3", style), answer("Later Three W3", style));
|
||||
assert_eq!(db.canonical("後の三", style), answer("後の三", style));
|
||||
}
|
||||
}
|
||||
// The name table is laid out like Hiragino Sans W3's, with a legacy family paired with its
|
||||
// legacy style.
|
||||
let three = named(&[
|
||||
(1, 0, 1, "Later Three"),
|
||||
(1, 0, 2, "W3"),
|
||||
(3, EN, 1, "Later Three W3"),
|
||||
(3, EN, 2, "Regular"),
|
||||
(3, EN, 6, "LThree-W3"),
|
||||
(3, EN, 16, "Later Three"),
|
||||
(3, EN, 17, "W3"),
|
||||
(3, JA, 16, "後の三"),
|
||||
(3, JA, 17, "W3"),
|
||||
]);
|
||||
std::fs::write(dir.join("LaterThree.ttf"), three).unwrap();
|
||||
assert_eq!(db.load_system_fonts(), 1);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(db.canonical("Later Three W3", "Regular"), answer("Later Three", "W3"));
|
||||
assert_eq!(db.canonical("Later Three W3", "Bold"), answer("Later Three", "Bold"));
|
||||
assert_eq!(db.canonical("後の三", "Bold"), answer("Later Three", "Bold"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Times the layout of a story of 1,000 paragraphs set in a font that is not installed, with 0, 100
|
||||
/// and 400 fonts added to the bundled ones. Run with
|
||||
/// `cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
|
||||
fn missing_font_story_layout_timing() {
|
||||
let text = vec!["Type set in a font that is not installed on this computer."; 1000].join("\n");
|
||||
let style = vectorcraft_doc::CharStyle { font_family: "No Such Font".into(), ..Default::default() };
|
||||
let t = TextObject::point(kurbo::Point::ZERO, &text, style);
|
||||
for extra in [0, 100, 400] {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for i in 0..extra {
|
||||
let (family, full, postscript) = (format!("Loaded Sans {i}"), format!("Loaded Sans {i} Regular"), format!("LoadedSans{i}-Regular"));
|
||||
assert_eq!(db.add_font(named(&[(3, EN, 1, &family), (3, EN, 2, "Regular"), (3, EN, 4, &full), (3, EN, 6, &postscript)])), 1);
|
||||
}
|
||||
let mut ms = vec![];
|
||||
for _ in 0..7 {
|
||||
let start = std::time::Instant::now();
|
||||
let l = layout(&db, &t);
|
||||
ms.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
assert_eq!(l.lines.len(), 1000);
|
||||
}
|
||||
ms.sort_by(f64::total_cmp);
|
||||
println!("{extra} fonts added, {} families loaded: median {:.1} ms; samples {ms:.1?}", db.family_list().len(), ms[3]);
|
||||
}
|
||||
}
|
||||
+27
-11
@@ -9,7 +9,7 @@
|
||||
//! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away
|
||||
//! from the viewer are left out, the rest are drawn back to front.
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect};
|
||||
|
||||
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
|
||||
|
||||
@@ -290,7 +290,11 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
|
||||
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
|
||||
/// few points and large ones more; more than `limit` points in all is refused.
|
||||
fn rings(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
///
|
||||
/// Under the nonzero `rule` an outline with filled art on both sides is no edge of the shape and
|
||||
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
|
||||
/// if nesting alone decided (#1724).
|
||||
fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let tolerance = (size / 2000.0).max(0.05);
|
||||
let mut count = 0;
|
||||
let mut out = vec![];
|
||||
@@ -325,6 +329,18 @@ fn rings(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, St
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
if rule == FillRule::NonZero {
|
||||
let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 };
|
||||
let mut edge = Vec::with_capacity(out.len());
|
||||
for (i, r) in out.iter().enumerate() {
|
||||
let p = r.first().copied().unwrap_or_default();
|
||||
// The winding number just outside the ring, and just inside it.
|
||||
let outside: i32 = out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).map(|(_, o)| turn(o)).sum();
|
||||
edge.push((outside != 0) != (outside + turn(r) != 0));
|
||||
}
|
||||
let mut edge = edge.into_iter();
|
||||
out.retain(|_| edge.next().unwrap_or(true));
|
||||
}
|
||||
// A ring inside an odd number of others is a hole.
|
||||
let holes: Vec<bool> = out
|
||||
.iter()
|
||||
@@ -634,14 +650,14 @@ fn steps(o: &Extrude) -> Vec<Step> {
|
||||
|
||||
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
|
||||
/// and caps.
|
||||
pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
|
||||
pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
|
||||
check_bounds(bounds)?;
|
||||
let o = options.sanitized();
|
||||
// No depth: the flat shape, seen from either side.
|
||||
if o.depth <= 0.0 {
|
||||
return plane(path, bounds, view);
|
||||
return plane(path, rule, bounds, view);
|
||||
}
|
||||
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, o.depth);
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
|
||||
let steps = steps(&o);
|
||||
@@ -675,9 +691,9 @@ pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> R
|
||||
|
||||
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
|
||||
/// shape's own, so that every part of one object turns about the same point).
|
||||
pub fn plane(path: &PathData, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
|
||||
pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
|
||||
check_bounds(bounds)?;
|
||||
let rings = rings(path, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, 0.0);
|
||||
let mut b = Builder { camera: &camera, closed: false, facets: vec![] };
|
||||
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
||||
@@ -997,18 +1013,18 @@ fn material(outline: &PathData, cell: Rect) -> Option<Vec<Vec<Point>>> {
|
||||
&[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)],
|
||||
true,
|
||||
));
|
||||
let rule = vectorcraft_geom::FillRule::NonZero;
|
||||
let rule = FillRule::NonZero;
|
||||
let part =
|
||||
vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
|
||||
.ok()?;
|
||||
rings(&part, cell.width(), MAX_PLANE_POINTS).ok()
|
||||
rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok()
|
||||
}
|
||||
|
||||
/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is.
|
||||
pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
|
||||
pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
|
||||
check_bounds(bounds)?;
|
||||
let o = options.sanitized();
|
||||
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
if rings.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user