mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 11:46:54 -04:00
Compare commits
93
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 | ||
|
|
9c91c36e7e |
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"
|
||||
|
||||
+5
-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 (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)
|
||||
> **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
|
||||
@@ -129,6 +129,9 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| 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
|
||||
@@ -165,6 +168,7 @@ 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. |
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+77
-9
@@ -31,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;
|
||||
@@ -775,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 {
|
||||
@@ -783,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.
|
||||
@@ -1376,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)
|
||||
@@ -1460,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 {
|
||||
@@ -1499,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1628,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.
|
||||
pub(crate) 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 @@ pub(crate) fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
pub(crate) 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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -273,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)
|
||||
}
|
||||
@@ -419,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);
|
||||
}
|
||||
}
|
||||
+44
-15
@@ -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;
|
||||
@@ -716,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,
|
||||
@@ -856,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 {
|
||||
@@ -942,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,
|
||||
@@ -1001,6 +1007,7 @@ impl Default for Prefs {
|
||||
perspective_presets: vec![],
|
||||
blend_options: None,
|
||||
perspective_widget: Default::default(),
|
||||
import_matches: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1229,22 +1236,17 @@ 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.
|
||||
@@ -1704,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)]
|
||||
@@ -1939,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();
|
||||
|
||||
@@ -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! {
|
||||
@@ -2533,6 +2618,333 @@ 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()
|
||||
@@ -2589,6 +3001,188 @@ fn deeply_nested_dxf_blocks_are_read_within_the_cap() {
|
||||
});
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -29,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>,
|
||||
@@ -101,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();
|
||||
@@ -140,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()],
|
||||
@@ -205,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 ----------
|
||||
|
||||
@@ -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;
|
||||
@@ -17,10 +17,95 @@ pub(crate) struct DictRef(Rc<RefCell<Dict>>);
|
||||
|
||||
impl DictRef {
|
||||
pub fn new(d: Dict) -> Self {
|
||||
Self(Rc::new(RefCell::new(d)))
|
||||
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 {
|
||||
@@ -70,6 +155,8 @@ fn drop_held(held: impl Iterator<Item = Obj>) {
|
||||
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 {}
|
||||
@@ -78,6 +165,9 @@ 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.
|
||||
@@ -219,7 +309,9 @@ impl<T: Held> Drop for Shared<T> {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
+112
-66
@@ -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,9 +147,15 @@ 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())?;
|
||||
picked.iter().filter_map(|&n| original.pages().get(n)).try_for_each(crate::pages::content_too_deep)?;
|
||||
let mut ocgs = Ocgs::read(&original);
|
||||
let route = opts.layers && !ocgs.list.is_empty();
|
||||
// Content that is off imports as hidden layers: an update of the file turns every group on
|
||||
@@ -167,7 +173,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let all_on = turned_on.is_some();
|
||||
let pdf = turned_on.unwrap_or(original);
|
||||
let pages = pdf.pages();
|
||||
picked.iter().filter_map(|&n| pages.get(n)).try_for_each(crate::pages::content_too_deep)?;
|
||||
|
||||
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
|
||||
let sink2 = sink.clone();
|
||||
@@ -176,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)
|
||||
@@ -1262,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;
|
||||
}
|
||||
@@ -1355,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))
|
||||
}
|
||||
@@ -1684,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) {
|
||||
@@ -1707,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;
|
||||
@@ -1729,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 {
|
||||
@@ -1753,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() });
|
||||
@@ -1764,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
|
||||
@@ -1799,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1834,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;
|
||||
@@ -1975,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};
|
||||
|
||||
+121
-58
@@ -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,75 +37,134 @@ pub fn is_postscript(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6])
|
||||
}
|
||||
|
||||
/// The deepest a file's arrays and dictionaries may nest. The PDF reader (hayro) reads them by
|
||||
/// recursion, without a cap of its own: a file nested deeply enough overflows the stack, which is
|
||||
/// an abort, not an error. Far beyond what a PDF writer makes.
|
||||
const MAX_OBJECT_NESTING: usize = 256;
|
||||
/// 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;
|
||||
|
||||
fn too_deep() -> PdfError {
|
||||
PdfError::Parse(format!("its arrays and dictionaries are nested more than {MAX_OBJECT_NESTING} deep"))
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Do the arrays and dictionaries in the text of `pdf` nest deeper than [`MAX_OBJECT_NESTING`]?
|
||||
/// Read as tokens (strings and comments hold none), an object at a time. The data of a stream
|
||||
/// with a filter is skipped: it isn't PDF text until decoded, which this doesn't do, so nesting
|
||||
/// inside compressed streams is not seen here.
|
||||
fn nests_too_deep(pdf: &[u8]) -> bool {
|
||||
let after =
|
||||
|from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len());
|
||||
let word = |at: usize, w: &[u8]| pdf.get(at..).is_some_and(|rest| rest.starts_with(w));
|
||||
let (mut depth, mut filtered) = (0usize, false);
|
||||
let mut i = 0;
|
||||
while let Some(&c) = pdf.get(i) {
|
||||
i += 1;
|
||||
/// 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'%' => i += pdf.get(i..).and_then(|rest| rest.iter().position(|c| matches!(c, b'\n' | b'\r'))).unwrap_or(pdf.len()),
|
||||
b'(' => {
|
||||
// A string: parentheses nest in it, a backslash takes the next byte.
|
||||
let mut open = 1usize;
|
||||
while open > 0 {
|
||||
match pdf.get(i) {
|
||||
None => return false,
|
||||
Some(b'\\') => i += 1,
|
||||
Some(b'(') => open += 1,
|
||||
Some(b')') => open -= 1,
|
||||
Some(_) => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'[' => depth += 1,
|
||||
b']' => depth = depth.saturating_sub(1),
|
||||
b'<' if pdf.get(i) == Some(&b'<') => (depth, i) = (depth + 1, i + 1),
|
||||
b'>' if pdf.get(i) == Some(&b'>') => (depth, i) = (depth.saturating_sub(1), i + 1),
|
||||
// A string in hexadecimal.
|
||||
b'<' => i = after(i, b">").unwrap_or(pdf.len()),
|
||||
b'/' if word(i - 1, b"/Filter") => filtered = true,
|
||||
b'e' if word(i - 1, b"endobj") => (depth, filtered) = (0, false),
|
||||
b'e' if word(i - 1, b"endstream") => i += 8,
|
||||
b's' if word(i - 1, b"stream") => {
|
||||
depth = 0;
|
||||
if std::mem::take(&mut filtered) {
|
||||
i = after(i, b"endstream").unwrap_or(pdf.len());
|
||||
}
|
||||
}
|
||||
b'{' => depth += 1,
|
||||
b'}' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if depth > MAX_OBJECT_NESTING {
|
||||
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 nests_too_deep(bytes) {
|
||||
return Err(too_deep());
|
||||
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);
|
||||
@@ -119,12 +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:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// The error of a page whose content, decoded, nests arrays and dictionaries too deep (see
|
||||
/// [`MAX_OBJECT_NESTING`]): its operands are read the same way as the file's objects.
|
||||
pub(crate) fn content_too_deep(page: &Page<'_>) -> Result<(), PdfError> {
|
||||
if page.page_stream().is_some_and(nests_too_deep) { Err(too_deep()) } else { Ok(()) }
|
||||
// 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
|
||||
|
||||
+66
-16
@@ -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| {
|
||||
@@ -1365,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);
|
||||
@@ -1386,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();
|
||||
}
|
||||
@@ -1482,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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -940,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
|
||||
@@ -1093,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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,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(
|
||||
|
||||
@@ -20,8 +20,22 @@ use super::css::{Styles, XNode, parse_decls};
|
||||
/// 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
|
||||
/// 1 MB stack.
|
||||
pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { 48 } else { 256 };
|
||||
/// 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+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![]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use vectorcraft_geom::{BezPath, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -41,7 +41,7 @@ fn close(a: f64, b: f64, tol: f64) -> bool {
|
||||
#[test]
|
||||
fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
|
||||
let (p, b) = shape(&square(10.0, 20.0, 100.0));
|
||||
let f = extrude(&p, b, Extrude::default(), front()).unwrap();
|
||||
let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows");
|
||||
assert_eq!(f[0].contours.len(), 1);
|
||||
assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0));
|
||||
@@ -51,7 +51,7 @@ fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
|
||||
fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 40.0, ..Extrude::default() };
|
||||
let f = extrude(&p, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
|
||||
let f = extrude(&p, FillRule::EvenOdd, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
|
||||
// Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°.
|
||||
let w = extent(&f).width();
|
||||
assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}");
|
||||
@@ -66,10 +66,10 @@ fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
|
||||
fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
|
||||
let flat = extrude(&p, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
|
||||
assert_eq!(flat, plane(&p, b, v).unwrap());
|
||||
let solid = extrude(&p, b, Extrude::default(), v).unwrap();
|
||||
let hollow = extrude(&p, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
|
||||
let flat = extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
|
||||
assert_eq!(flat, plane(&p, FillRule::EvenOdd, b, v).unwrap());
|
||||
let solid = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap();
|
||||
let hollow = extrude(&p, FillRule::EvenOdd, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
|
||||
// Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps.
|
||||
assert_eq!(solid.len(), 3);
|
||||
assert_eq!(hollow.len(), 4);
|
||||
@@ -81,10 +81,10 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() };
|
||||
let straight = extent(&extrude(&p, b, o, side).unwrap());
|
||||
let straight = extent(&extrude(&p, FillRule::EvenOdd, b, o, side).unwrap());
|
||||
assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6));
|
||||
// Seen from the side, a 50 % taper keeps the front's full height and halves the back's.
|
||||
let tapered = extrude(&p, b, Extrude { taper: 0.5, ..o }, side).unwrap();
|
||||
let tapered = extrude(&p, FillRule::EvenOdd, b, Extrude { taper: 0.5, ..o }, side).unwrap();
|
||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect();
|
||||
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
|
||||
let at_front: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect();
|
||||
@@ -93,11 +93,11 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||
assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}");
|
||||
// Seen from behind, a 45° twist shows the back cap turned into a diamond.
|
||||
let back = View { rotation_y: 180.0, ..front() };
|
||||
let twisted = extrude(&p, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
|
||||
let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
|
||||
assert_eq!(twisted.len(), 1, "only the back cap faces the viewer");
|
||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||
// Its walls are cut into slices that follow the turn.
|
||||
let open = extrude(&p, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
let open = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
assert!(open.len() > 2 * 8, "{}", open.len());
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
|
||||
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
|
||||
let inside = extrude(&p, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
|
||||
let inside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
|
||||
// Straight on: the cap inset by the width, and the chamfer around it filling the outline.
|
||||
let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6));
|
||||
assert!(cap.is_some(), "an 80 × 80 cap");
|
||||
assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(close(area(&inside), 100.0 * 100.0, 1e-6));
|
||||
let outside = extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
|
||||
let outside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
|
||||
assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art");
|
||||
assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art");
|
||||
// The chamfer's faces lean towards the light differently from the cap.
|
||||
@@ -123,12 +123,16 @@ fn bevel_shapes_repeats_and_both_sides_change_the_surface() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||
let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() };
|
||||
let classic = extrude(&p, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
|
||||
let classic = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
|
||||
for shape in BevelShape::ALL.into_iter().skip(1) {
|
||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), "{shape:?}");
|
||||
assert_ne!(
|
||||
classic,
|
||||
extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(),
|
||||
"{shape:?}"
|
||||
);
|
||||
}
|
||||
for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] {
|
||||
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
|
||||
assert_ne!(classic, extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
|
||||
}
|
||||
// The step shape keeps its corners: half the width at half the height.
|
||||
assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5));
|
||||
@@ -147,11 +151,11 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
|
||||
// The hole drawn the same way round as the outline: even-odd nesting still makes it a hole.
|
||||
b.extend(square(30.0, 30.0, 40.0));
|
||||
let (p, r) = shape(&b);
|
||||
let f = extrude(&p, r, Extrude::default(), front()).unwrap();
|
||||
let f = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].contours.len(), 2);
|
||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6));
|
||||
let turned = extrude(&p, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
|
||||
let turned = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
|
||||
// Front cap, the outline's right wall and the hole's left wall (seen through the hole).
|
||||
assert_eq!(turned.len(), 3);
|
||||
}
|
||||
@@ -159,15 +163,15 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
|
||||
#[test]
|
||||
fn a_plane_turns_about_the_given_centre() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let f = plane(&p, b, View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
let f = plane(&p, FillRule::EvenOdd, b, View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
assert_eq!(f.len(), 1);
|
||||
let e = extent(&f);
|
||||
assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9));
|
||||
// Turned about the centre of a larger box, the square moves as well as narrowing.
|
||||
let wide = plane(&p, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
let wide = plane(&p, FillRule::EvenOdd, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
|
||||
assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6));
|
||||
// Its back shows when turned past 90°: a plane is two-sided.
|
||||
assert_eq!(plane(&p, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
|
||||
assert_eq!(plane(&p, FillRule::EvenOdd, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -175,7 +179,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() {
|
||||
let mut c = BezPath::new();
|
||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||
let (p, b) = shape(&c);
|
||||
let f = inflate(&p, b, Inflate::default(), front()).unwrap();
|
||||
let f = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), front()).unwrap();
|
||||
// Straight on, the cushion covers the circle (sampled as a polygon) and nothing else.
|
||||
assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f));
|
||||
assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6);
|
||||
@@ -184,7 +188,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() {
|
||||
assert!(hi - lo > 0.3, "{lo}..{hi}");
|
||||
// From the side: 50 % volume rounds a disc into a half sphere on a flat back.
|
||||
let side = View { rotation_y: 90.0, ..front() };
|
||||
let thick = |o: Inflate| extent(&inflate(&p, b, o, side).unwrap()).width();
|
||||
let thick = |o: Inflate| extent(&inflate(&p, FillRule::EvenOdd, b, o, side).unwrap()).width();
|
||||
assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default()));
|
||||
assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0));
|
||||
assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0));
|
||||
@@ -197,7 +201,7 @@ fn inflate_leaves_holes_open() {
|
||||
let mut b = square(0.0, 0.0, 100.0);
|
||||
b.extend(square(30.0, 30.0, 40.0));
|
||||
let (p, r) = shape(&b);
|
||||
let f = inflate(&p, r, Inflate::default(), front()).unwrap();
|
||||
let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap();
|
||||
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f));
|
||||
}
|
||||
|
||||
@@ -205,9 +209,9 @@ fn inflate_leaves_holes_open() {
|
||||
fn malformed_and_degenerate_input_is_bounded() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] {
|
||||
assert!(extrude(&p, r, Extrude::default(), front()).is_err());
|
||||
assert!(inflate(&p, r, Inflate::default(), front()).is_err());
|
||||
assert!(plane(&p, r, front()).is_err());
|
||||
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_err());
|
||||
assert!(inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).is_err());
|
||||
assert!(plane(&p, FillRule::EvenOdd, r, front()).is_err());
|
||||
}
|
||||
let wild = Extrude {
|
||||
depth: f64::INFINITY,
|
||||
@@ -216,18 +220,24 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||
cap: true,
|
||||
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
|
||||
};
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
|
||||
let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
|
||||
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert!(
|
||||
inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front())
|
||||
.is_ok()
|
||||
inflate(
|
||||
&p,
|
||||
FillRule::EvenOdd,
|
||||
b,
|
||||
Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true },
|
||||
front()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
assert!(extrude(&PathData::default(), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// A line has no area to extrude.
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((100.0, 0.0));
|
||||
assert!(extrude(&PathData::from_bezpath(&line), b, Extrude::default(), front()).unwrap().is_empty());
|
||||
assert!(extrude(&PathData::from_bezpath(&line), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty());
|
||||
// Too many anchors: refused, not truncated.
|
||||
let mut many = BezPath::new();
|
||||
many.move_to((0.0, 0.0));
|
||||
@@ -235,15 +245,15 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||
many.line_to((f64::from(i), f64::from(i % 7)));
|
||||
}
|
||||
many.close_path();
|
||||
assert!(extrude(&PathData::from_bezpath(&many), b, Extrude::default(), front()).is_err());
|
||||
assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_makes_the_near_face_larger() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||
let flat = extent(&extrude(&p, b, o, front()).unwrap());
|
||||
let deep = extent(&extrude(&p, b, o, View { perspective: 30.0, ..front() }).unwrap());
|
||||
let flat = extent(&extrude(&p, FillRule::EvenOdd, b, o, front()).unwrap());
|
||||
let deep = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: 30.0, ..front() }).unwrap());
|
||||
assert!(deep.width() > flat.width() + 1.0);
|
||||
assert!(close(deep.center().x, flat.center().x, 1e-6));
|
||||
}
|
||||
@@ -257,7 +267,7 @@ fn a_wider_perspective_brings_the_eye_nearer() {
|
||||
let mut last = 100.0;
|
||||
for angle in [20.0, 60.0, 110.0, 160.0] {
|
||||
// The front face alone shows: its width is the cube's on the page.
|
||||
let width = extent(&extrude(&p, b, o, View { perspective: angle, ..front() }).unwrap()).width();
|
||||
let width = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: angle, ..front() }).unwrap()).width();
|
||||
assert!(width > last, "{angle}°: {width} after {last}");
|
||||
last = width;
|
||||
}
|
||||
@@ -274,10 +284,10 @@ fn the_inflate_membrane_matches_known_solutions() {
|
||||
let mut c = BezPath::new();
|
||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
|
||||
let (p, b) = shape(&c);
|
||||
let disc = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
|
||||
let disc = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
|
||||
assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak);
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let sq = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
|
||||
let sq = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
|
||||
assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak);
|
||||
// Smooth across the middle: no crease where a distance-to-the-edge height would have one
|
||||
// (the diagonals). The second difference along a diagonal stays small and even.
|
||||
@@ -303,10 +313,10 @@ fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() {
|
||||
b.close_path();
|
||||
let (p, r) = shape(&b);
|
||||
let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() };
|
||||
let f = inflate(&p, r, Inflate::default(), v).unwrap();
|
||||
let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), v).unwrap();
|
||||
assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface");
|
||||
// Straight on, the facets still cover exactly the shape.
|
||||
let flat = inflate(&p, r, Inflate::default(), front()).unwrap();
|
||||
let flat = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap();
|
||||
assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat));
|
||||
}
|
||||
|
||||
@@ -332,19 +342,19 @@ fn bumps(count: usize) -> (PathData, Rect) {
|
||||
fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
|
||||
// 300 curves were 4,800 points at sixteen each, over the old limit of 1,024.
|
||||
let (p, r) = bumps(300);
|
||||
assert!(extrude(&p, r, Extrude::default(), front()).is_ok());
|
||||
assert!(inflate(&p, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
|
||||
assert!(plane(&p, r, front()).is_ok());
|
||||
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok());
|
||||
assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
|
||||
assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok());
|
||||
// 4,000 curves: too many for a solid, not for Rotate.
|
||||
let (p, r) = bumps(4000);
|
||||
assert!(extrude(&p, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
|
||||
assert_eq!(plane(&p, r, front()).unwrap().len(), 1);
|
||||
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
|
||||
assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1);
|
||||
// Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt
|
||||
// here (1/2000 of its 100 pt size is less).
|
||||
let mut c = BezPath::new();
|
||||
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0), 0.01));
|
||||
let (p, r) = shape(&c);
|
||||
let ring = &rings(&p, 100.0, MAX_SOLID_POINTS).unwrap()[0];
|
||||
let ring = &rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap()[0];
|
||||
for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)) {
|
||||
let mid = a.midpoint(*b);
|
||||
assert!(50.0 - mid.distance(Point::new(50.0, 50.0)) <= 0.05 + 1e-3, "{a:?} {b:?}");
|
||||
@@ -352,7 +362,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
|
||||
assert!(r.width() > 0.0);
|
||||
// A small bump takes a few points; a large curve more than sixteen where it needs them.
|
||||
let (p, r) = bumps(300);
|
||||
let small = rings(&p, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len();
|
||||
let small = rings(&p, FillRule::EvenOdd, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len();
|
||||
assert!(small <= 300 * 4 + 3, "{small} points for 300 bumps");
|
||||
assert!(ring.len() > 4 * 16, "a 100 pt circle within 0.05 pt: {}", ring.len());
|
||||
}
|
||||
@@ -362,7 +372,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
|
||||
#[test]
|
||||
fn the_edge_index_finds_the_nearest_edge() {
|
||||
let (p, r) = bumps(200);
|
||||
let rings = rings(&p, r.width(), MAX_SOLID_POINTS).unwrap();
|
||||
let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap();
|
||||
let edges = Edges::new(&rings);
|
||||
for k in 0..400 {
|
||||
let t = f64::from(k);
|
||||
@@ -376,7 +386,7 @@ fn the_edge_index_finds_the_nearest_edge() {
|
||||
/// straight ahead: the search doesn't stop before it has looked there.
|
||||
#[test]
|
||||
fn the_edge_index_looks_along_the_side_from_outside() {
|
||||
let ring = |b: BezPath| rings(&shape(&b).0, 64.0, MAX_SOLID_POINTS).unwrap().remove(0);
|
||||
let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0);
|
||||
// Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid).
|
||||
let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))];
|
||||
let edges = Edges::new(&rings);
|
||||
@@ -390,7 +400,7 @@ fn the_edge_index_looks_along_the_side_from_outside() {
|
||||
fn twist_holds_between_0_and_360_degrees() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
|
||||
let at = |twist: f64| extrude(&p, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
|
||||
let at = |twist: f64| extrude(&p, FillRule::EvenOdd, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
|
||||
assert_eq!(at(-45.0), at(0.0));
|
||||
assert_eq!(at(500.0), at(360.0));
|
||||
assert_ne!(at(45.0), at(0.0));
|
||||
@@ -408,11 +418,11 @@ fn twisted_walls_face_the_way_they_turn() {
|
||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] {
|
||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||
let o = Extrude { twist, ..Extrude::default() };
|
||||
let got = extrude(&p, b, o, v).unwrap();
|
||||
let got = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap();
|
||||
// Expected: walls (and caps) whose outward normal faces the viewer.
|
||||
let o = o.sanitized();
|
||||
let camera = Camera::new(v, b, o.depth);
|
||||
let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
|
||||
let ring = &rings(&p, FillRule::EvenOdd, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
|
||||
let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64);
|
||||
let z = |t: f64| o.depth / 2.0 - o.depth * t;
|
||||
let mut expected = 0;
|
||||
@@ -456,7 +466,7 @@ fn inflate_twists_tapers_and_opens_its_back() {
|
||||
let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() };
|
||||
// From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50
|
||||
// at a 50 % taper.
|
||||
let tapered = inflate(&p, b, Inflate { taper: 0.5, ..o }, side).unwrap();
|
||||
let tapered = inflate(&p, FillRule::EvenOdd, b, Inflate { taper: 0.5, ..o }, side).unwrap();
|
||||
let e = extent(&tapered);
|
||||
assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}");
|
||||
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect();
|
||||
@@ -464,18 +474,19 @@ fn inflate_twists_tapers_and_opens_its_back() {
|
||||
assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo);
|
||||
// From behind, a 45° twist shows the flat back as a diamond.
|
||||
let behind = View { rotation_y: 180.0, ..front() };
|
||||
let twisted = inflate(&p, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
|
||||
let twisted = inflate(&p, FillRule::EvenOdd, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
|
||||
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
|
||||
// Hollow: no back; from behind the inside of the inflated front shows instead of a flat face.
|
||||
let solid = inflate(&p, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
|
||||
let solid = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
|
||||
assert_eq!(solid.len(), 1, "the flat back alone");
|
||||
let hollow = inflate(&p, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
|
||||
let hollow = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
|
||||
assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12));
|
||||
// With both sides puffed, the back puff is scaled with the taper: lower and smaller.
|
||||
let both = |taper: f64| extent(&inflate(&p, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
|
||||
let both =
|
||||
|taper: f64| extent(&inflate(&p, FillRule::EvenOdd, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
|
||||
assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6);
|
||||
// Out of range values hold at the documented ends.
|
||||
let at = |i: Inflate| inflate(&p, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
let at = |i: Inflate| inflate(&p, FillRule::EvenOdd, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
|
||||
assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o));
|
||||
assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o }));
|
||||
}
|
||||
@@ -487,10 +498,43 @@ fn a_flat_twisted_inflate_matches_the_extrusion() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] {
|
||||
let v = View { rotation_x: x, rotation_y: y, ..front() };
|
||||
let e = extent(&extrude(&p, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
|
||||
let i = extent(&inflate(&p, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap());
|
||||
let e = extent(&extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
|
||||
let i = extent(
|
||||
&inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap(),
|
||||
);
|
||||
for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] {
|
||||
assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole
|
||||
/// (the middle is filled, as the art is drawn); one running the other way does, and so does
|
||||
/// either under the even-odd rule.
|
||||
#[test]
|
||||
fn nonzero_shapes_keep_the_middle_a_same_way_contour_encloses() {
|
||||
let rect = |r: Rect, clockwise: bool| {
|
||||
let mut pts = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
|
||||
if clockwise {
|
||||
pts.reverse();
|
||||
}
|
||||
vectorcraft_geom::SubPath::polyline(&pts, true)
|
||||
};
|
||||
let (outer, inner) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(25.0, 25.0, 75.0, 75.0));
|
||||
let nested = |inner_clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, inner_clockwise)]);
|
||||
let flat = |p: &PathData, rule: FillRule| area(&plane(p, rule, outer, front()).unwrap());
|
||||
let (solid, holed) = (100.0 * 100.0, 100.0 * 100.0 - 50.0 * 50.0);
|
||||
assert!(close(flat(&nested(false), FillRule::NonZero), solid, 1e-6), "the same way round: filled");
|
||||
assert!(close(flat(&nested(true), FillRule::NonZero), holed, 1e-6), "the other way round: a hole");
|
||||
assert!(close(flat(&nested(false), FillRule::EvenOdd), holed, 1e-6));
|
||||
assert!(close(flat(&nested(true), FillRule::EvenOdd), holed, 1e-6));
|
||||
// A third contour inside the hole is an island again, whichever way it runs.
|
||||
let island = |clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, true), rect(Rect::new(40.0, 40.0, 60.0, 60.0), clockwise)]);
|
||||
for clockwise in [false, true] {
|
||||
assert!(close(flat(&island(clockwise), FillRule::NonZero), holed + 20.0 * 20.0, 1e-6), "{clockwise}");
|
||||
}
|
||||
// Extrude's caps and Inflate follow the same outline.
|
||||
let caps = |p: &PathData| area(&extrude(p, FillRule::NonZero, outer, Extrude::default(), front()).unwrap());
|
||||
assert!(caps(&nested(false)) > caps(&nested(true)) + 1000.0);
|
||||
assert!(inflate(&nested(false), FillRule::NonZero, outer, Inflate::default(), front()).is_ok());
|
||||
}
|
||||
@@ -730,7 +730,8 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
sel.retain(|(id, _)| cx.doc.is_editable(*id));
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}))]
|
||||
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r)).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -1100,6 +1101,26 @@ mod tests {
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_ruler_guides() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![
|
||||
Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"})),
|
||||
Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A path inside an inner group, a sibling path outside it, and the groups' ids.
|
||||
/// The inside path is the rectangle at (100, 100); the outside one at (300, 300).
|
||||
fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) {
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! made where it is released over the canvas: with the Artboard tool, an artboard guide of the
|
||||
//! active artboard. For the selection tools ([`GuideEdit`]), a press on a guide selects it (Shift
|
||||
//! adds it to the selected guides or, released without a drag, takes it out); dragging moves the
|
||||
//! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the
|
||||
//! selected guides (Alt copies them), and a marquee selects the guides it crosses along with the
|
||||
//! art ([`marquee_select`]). A dragged guide snaps ([`GuideSnap`]): with Shift to the
|
||||
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
|
||||
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
|
||||
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden
|
||||
//! or locked layer, can't be picked.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::guides::Targets;
|
||||
use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext};
|
||||
@@ -32,6 +33,35 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// What a selection tool's marquee `r` does to the ruler guides, after its action selecting the
|
||||
/// art: the guides it crosses join the selection (with a Shift marquee, the selected ones leave
|
||||
/// it, as with a Shift-click on a guide).
|
||||
pub fn marquee_select(cx: &ToolContext, r: Rect) -> Option<Action> {
|
||||
let guides = guides_in_rect(cx, r);
|
||||
(!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true })))
|
||||
}
|
||||
|
||||
/// The editable ruler guides intersecting marquee rectangle `r`.
|
||||
fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec<usize> {
|
||||
if !cx.guides {
|
||||
return vec![];
|
||||
}
|
||||
cx.doc
|
||||
.guides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, g)| cx.doc.guide_editable(g))
|
||||
.filter(|(_, g)| {
|
||||
if g.vertical {
|
||||
r.min_x() <= g.pos && g.pos <= r.max_x() && cx.doc.guide_span(g).is_none_or(|(y0, y1)| r.max_y() >= y0 && r.min_y() <= y1)
|
||||
} else {
|
||||
r.min_y() <= g.pos && g.pos <= r.max_y() && cx.doc.guide_span(g).is_none_or(|(x0, x1)| r.max_x() >= x0 && r.min_x() <= x1)
|
||||
}
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pointer for a guide: it moves sideways if vertical, up and down if horizontal.
|
||||
fn guide_cursor(vertical: bool) -> Cursor {
|
||||
if vertical { Cursor::ResizeH } else { Cursor::ResizeV }
|
||||
|
||||
@@ -459,7 +459,8 @@ impl Tool for SelectionTool {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
let ids: Vec<NodeId> = marquee(cx.doc, r, cx.isolation, false);
|
||||
vec![Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }))]
|
||||
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }));
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r)).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -993,6 +994,58 @@ mod tests {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_ruler_guides() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0));
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![
|
||||
Action::Exec("select.set".into(), json!({"ids": [id.0]})),
|
||||
Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_only_guide() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide::new(true, 20.0));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 30.0));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0));
|
||||
assert_eq!(
|
||||
a,
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": []})), Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_ignores_locked_guides() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let mut cx_locked = cx(&d, &s, &p);
|
||||
cx_locked.guides = false;
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx_locked, &ev(PointerKind::Down, 50.0, 50.0));
|
||||
t.pointer(&cx_locked, &ev(PointerKind::Drag, 120.0, 120.0));
|
||||
let a = t.pointer(&cx_locked, &ev(PointerKind::Up, 120.0, 120.0));
|
||||
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_drag_scales() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -33,6 +33,11 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `now_ms` (src/lib.rs) reads the page's `performance.now()` with this crate in the web build. It is
|
||||
# MIT OR Apache-2.0 and already in the build through eframe.
|
||||
web-time = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -153,6 +153,11 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.available_rect_before_wrap();
|
||||
// `hidden_chars` holds the marks Show Hidden Characters kept and the text nodes they were laid
|
||||
// out from. Only the assignment right before the overlay call below puts them back. A frame
|
||||
// that does not draw the overlay drops them, for example with the option off, with the edges
|
||||
// hidden, on the Home screen or with no document open.
|
||||
let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars);
|
||||
// A document that opened, closed or became active since Home was chosen replaces it.
|
||||
if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) {
|
||||
app.ui.home = None;
|
||||
@@ -438,6 +443,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
thread_overlay(app, &painter, &xf);
|
||||
}
|
||||
if app.ui.view.hidden_chars {
|
||||
app.canvas.hidden_chars = hidden_chars;
|
||||
hidden_chars_overlay(app, &painter, &xf);
|
||||
}
|
||||
}
|
||||
@@ -1530,8 +1536,8 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let color = Color32::from_rgb(0x4f, 0x9d, 0xff);
|
||||
let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0));
|
||||
let kept = &mut app.canvas.hidden_chars;
|
||||
// `last` holds the marks of the last frame that showed them. The marks of the objects this
|
||||
// frame doesn't draw are dropped at the end.
|
||||
// `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw
|
||||
// are dropped at the end.
|
||||
let mut last = std::mem::take(&mut kept.marks);
|
||||
// The inputs are read before any layout: when fonts load during this frame, the next frame
|
||||
// lays all type out again.
|
||||
@@ -1569,7 +1575,7 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The Show Hidden Characters marks of the text objects drawn in the last frame that showed them
|
||||
/// The Show Hidden Characters marks of the text objects drawn in the last frame
|
||||
/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an
|
||||
/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the
|
||||
/// fonts or the hyphenation exceptions change.
|
||||
@@ -3763,6 +3769,66 @@ mod tests {
|
||||
assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived");
|
||||
}
|
||||
|
||||
/// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]).
|
||||
fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
|
||||
let blue = Color32::from_rgb(0x4f, 0x9d, 0xff);
|
||||
shapes(app, ctx)
|
||||
.into_iter()
|
||||
.filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before.
|
||||
/// Turning it off drops the marks with their text nodes, and turning it on again draws the
|
||||
/// same marks.
|
||||
#[test]
|
||||
fn show_hidden_characters_off_releases_the_text_nodes() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"});
|
||||
let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||||
let on = hidden_char_marks(&mut app, &ctx);
|
||||
assert!(on.len() > 2, "{on:?}");
|
||||
// Tests running alongside add fonts and set hyphenation exceptions, which lays all type out
|
||||
// again: frames are drawn until neither changed between two of them.
|
||||
let kept = (0..100).any(|_| {
|
||||
let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone());
|
||||
hidden_char_marks(&mut app, &ctx);
|
||||
let after = &app.canvas.hidden_chars;
|
||||
after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks)
|
||||
});
|
||||
assert!(kept, "a frame keeps the marks of the frame before");
|
||||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||||
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off");
|
||||
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept");
|
||||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||||
assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks");
|
||||
}
|
||||
|
||||
/// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and
|
||||
/// the same type in a new document shows the same marks.
|
||||
#[test]
|
||||
fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||||
let open = |app: &mut VectorcraftApp| {
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap();
|
||||
hidden_char_marks(app, &ctx)
|
||||
};
|
||||
let first = open(&mut app);
|
||||
assert!(first.len() > 2, "{first:?}");
|
||||
assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node");
|
||||
app.session.execute("file.close", &json!({})).unwrap();
|
||||
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen");
|
||||
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept");
|
||||
assert_eq!(open(&mut app), first, "the same type in a new document");
|
||||
}
|
||||
|
||||
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
|
||||
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
|
||||
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
|
||||
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -63,12 +65,41 @@ pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
state: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
/// A request's state: waiting to run, taken by the app ([`ControlRequest::claim`]), or given up
|
||||
/// by its caller before that ([`Ticket::give_up`]). It leaves `QUEUED` once, one way or the other.
|
||||
const QUEUED: u8 = 0;
|
||||
const STARTED: u8 = 1;
|
||||
const GIVEN_UP: u8 = 2;
|
||||
|
||||
/// The caller's side of a queued [`ControlRequest`].
|
||||
pub struct Ticket(Arc<AtomicU8>);
|
||||
|
||||
impl Ticket {
|
||||
/// Stop waiting for the request → `true` when it had not started, and so never runs: a caller
|
||||
/// told "timeout" can then retry without the edit being applied twice, or applied later when
|
||||
/// nobody expects it (#1725). `false`: the app is running it (or has); its outcome is unknown.
|
||||
pub fn give_up(&self) -> bool {
|
||||
self.0.compare_exchange(QUEUED, GIVEN_UP, Ordering::SeqCst, Ordering::SeqCst).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx }, rx)
|
||||
(Self { method: method.into(), params, reply: tx, state: Arc::new(AtomicU8::new(QUEUED)) }, rx)
|
||||
}
|
||||
|
||||
/// The caller's handle on this request, to give it up with.
|
||||
pub fn ticket(&self) -> Ticket {
|
||||
Ticket(self.state.clone())
|
||||
}
|
||||
|
||||
/// Take the request to run it → `false` when its caller gave it up meanwhile (don't run it).
|
||||
pub fn claim(&self) -> bool {
|
||||
self.state.compare_exchange(QUEUED, STARTED, Ordering::SeqCst, Ordering::SeqCst).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,27 @@ pub const MESSAGE: &str = "message";
|
||||
|
||||
pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { ok: None, ..SPEC };
|
||||
|
||||
/// Most messages kept for later while a dialog is open ([`tell`]).
|
||||
const MAX_WAITING: usize = 16;
|
||||
|
||||
/// Tell the user `message` (with an optional `detail` line), both in the UI language, in a dialog
|
||||
/// they close.
|
||||
/// they close. While another dialog is open (the message comes from work in the background) it
|
||||
/// waits its turn instead of replacing it: neither what the user was doing there nor the message
|
||||
/// is lost.
|
||||
pub fn tell(app: &mut VectorcraftApp, message: &str, detail: &str) {
|
||||
app.ui.dialog = Some(Dialog::new(MESSAGE, json!({"message": message, "detail": detail})));
|
||||
let d = Dialog::new(MESSAGE, json!({"message": message, "detail": detail}));
|
||||
if app.ui.dialog.is_none() {
|
||||
app.ui.dialog = Some(d);
|
||||
} else if app.ui.told.len() < MAX_WAITING && !app.ui.dialog.iter().chain(&app.ui.told).any(|t| t.kind == MESSAGE && t.fields == d.fields) {
|
||||
app.ui.told.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the next message that waited for a dialog to close (each frame).
|
||||
pub(super) fn show_waiting(app: &mut VectorcraftApp) {
|
||||
if app.ui.dialog.is_none() && !app.ui.told.is_empty() {
|
||||
app.ui.dialog = Some(app.ui.told.remove(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes before running, so a dialog the command opens stays open.
|
||||
|
||||
@@ -80,6 +80,7 @@ pub mod save_style_library;
|
||||
pub mod save_swatch_library;
|
||||
pub mod script_check;
|
||||
mod shapes;
|
||||
pub mod shortcut_import;
|
||||
pub mod slices;
|
||||
pub mod spot_colors;
|
||||
pub(crate) mod svg_options;
|
||||
@@ -305,6 +306,7 @@ registry! {
|
||||
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
|
||||
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
|
||||
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
|
||||
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
|
||||
}
|
||||
|
||||
/// The button labels the shared dialog frame can show (OK, discard and the fixed Cancel/Close),
|
||||
@@ -333,11 +335,24 @@ fn run_and_close(app: &mut VectorcraftApp, id: &str, params: Value) -> DialogRes
|
||||
r
|
||||
}
|
||||
|
||||
/// Close the open dialog as Cancel does, rolling back a live preview (`ui.dialog.cancel`).
|
||||
/// The field of a dialog opened over another (Import Shortcut Set over Keyboard Shortcuts) that
|
||||
/// holds the dialog under it, which Cancel and Escape show again.
|
||||
pub(crate) const PARENT: &str = "__parent";
|
||||
|
||||
/// The dialog `d` was opened over, as it was.
|
||||
pub(crate) fn parent(d: &Dialog) -> Option<Dialog> {
|
||||
d.fields.get(PARENT).and_then(|v| serde::Deserialize::deserialize(v).ok())
|
||||
}
|
||||
|
||||
/// Close the open dialog as Cancel does, rolling back a live preview and showing the dialog it was
|
||||
/// opened over (`ui.dialog.cancel`).
|
||||
pub fn cancel(app: &mut VectorcraftApp) {
|
||||
missing_fonts::requeue_replaced(app);
|
||||
if app.ui.dialog.take().is_some_and(|d| spec(&d.kind).preview) {
|
||||
let _ = app.session.cancel_interaction();
|
||||
if let Some(d) = app.ui.dialog.take() {
|
||||
if spec(&d.kind).preview {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
app.ui.dialog = parent(&d);
|
||||
}
|
||||
settle(app);
|
||||
}
|
||||
@@ -366,6 +381,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
about::show(app, ctx);
|
||||
settle(app);
|
||||
confirm::show_waiting(app);
|
||||
// The kind of dialog shown last frame: a different one (or none) means this one just opened, and
|
||||
// its first field is to take the keyboard focus (`focus_id`, until a field takes it).
|
||||
let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending"));
|
||||
@@ -540,3 +556,6 @@ mod tests_modal;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_rectangular_grid;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_shortcut_import;
|
||||
@@ -268,7 +268,10 @@ mod tests {
|
||||
let written = Written::default();
|
||||
let w = written.clone();
|
||||
let services = Services {
|
||||
download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))),
|
||||
download: Some(Box::new(move |name: &str, b: &[u8]| {
|
||||
w.borrow_mut().push((name.to_string(), b.to_vec()));
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(with_art(services), written)
|
||||
|
||||
@@ -377,7 +377,7 @@ pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str],
|
||||
for s in sections {
|
||||
let sel = *s == section;
|
||||
let label = egui::RichText::new(tl!(*s)).size(12.5).color(if sel { t.text_strong } else { t.text });
|
||||
if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
|
||||
if ui.add(egui::Button::selectable(sel, label).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
|
||||
d.fields.insert("__section".into(), json!(s));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
//! Import Shortcut Set: a shortcut set another app saved as text (`.kys`, read by `shortcutSet.read`),
|
||||
//! reviewed before it fills Edit › Keyboard Shortcuts' working set.
|
||||
//!
|
||||
//! Each entry's name is matched against the tools, commands and panels Keyboard Shortcuts lists
|
||||
//! ([`vectorcraft_engine::namematch`]). Its key and modifier codes are decoded from the user's earlier
|
||||
//! answers (`importMatches`), from the two codes the format's public description gives (`/Key 48` is
|
||||
//! the 0 key and `/Modifiers 0` is none), and by agreement with VectorCraft's default shortcuts: each
|
||||
//! entry whose match has a default votes for what its codes stand for, and a code is proposed when at
|
||||
//! least two entries vote for it and four votes in five agree. The user answers the rest in the Keys
|
||||
//! section. Apply assigns the rows that are ready, in the file's order, through
|
||||
//! [`shortcut_editor::assign`]; when two rows want one shortcut, the later row takes it. Keyboard
|
||||
//! Shortcuts then shows the set, and its OK keeps it.
|
||||
//!
|
||||
//! Fields: `name`, `start` (`defaults` or `current`), `remember`, `rows` ([`Row`]s), `keys` and `mods`
|
||||
//! ([`Code`]s by code), `notes` (`[{line, text}]`: the parts of the file not read), `truncated`,
|
||||
//! `__parent` (the Keyboard Shortcuts dialog it was opened from, which Apply, Cancel and Escape go back
|
||||
//! to), `__recording` (the key code waiting for a key press), `__keysOpen` (the Keys section is open:
|
||||
//! at first when a code is not known), and `__query` and `__attention` (the filter of the rows shown).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::namematch::{Candidate, Matcher, Proposal};
|
||||
|
||||
use crate::shortcut_editor::{self, Entry};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{FilePick, VectorcraftApp, menus, widgets};
|
||||
|
||||
/// The dialog kind.
|
||||
pub const KIND: &str = "shortcutImport";
|
||||
|
||||
/// The files Keyboard Shortcuts' Import… lists: VectorCraft's own sets and sets another app saved as
|
||||
/// text.
|
||||
pub const IMPORT_EXTS: &[&str] = &["json", "kys"];
|
||||
|
||||
/// The codes the format's public description gives: `/Key 48` is the 0 key.
|
||||
const DESCRIBED_KEYS: &[(i64, &str)] = &[(48, "0")];
|
||||
/// `/Modifiers 0` is none.
|
||||
const DESCRIBED_MODS: &[(i64, &str)] = &[(0, "")];
|
||||
|
||||
/// The least votes a code needs for agreement to propose it.
|
||||
const MIN_VOTES: usize = 2;
|
||||
/// The share of the votes that must agree: four in five.
|
||||
const AGREE: (usize, usize) = (4, 5);
|
||||
/// The most entry names a code lists.
|
||||
const NAMES_SHOWN: usize = 3;
|
||||
|
||||
/// The modifiers the Keys section offers, as Edit › Keyboard Shortcuts writes them ("" is none).
|
||||
const MODIFIERS: &[&str] = &[
|
||||
"",
|
||||
"Cmd",
|
||||
"Ctrl",
|
||||
"Alt",
|
||||
"Shift",
|
||||
"Cmd+Ctrl",
|
||||
"Cmd+Alt",
|
||||
"Cmd+Shift",
|
||||
"Ctrl+Alt",
|
||||
"Ctrl+Shift",
|
||||
"Alt+Shift",
|
||||
"Cmd+Ctrl+Alt",
|
||||
"Cmd+Ctrl+Shift",
|
||||
"Cmd+Alt+Shift",
|
||||
"Ctrl+Alt+Shift",
|
||||
"Cmd+Ctrl+Alt+Shift",
|
||||
];
|
||||
|
||||
/// The table's row height and column widths (points).
|
||||
const ROW_H: f32 = 22.0;
|
||||
const NAME_W: f32 = 200.0;
|
||||
const TARGET_W: f32 = 230.0;
|
||||
const CHORD_W: f32 = 90.0;
|
||||
/// The tallest the Keys and Shortcuts lists get (points).
|
||||
const KEYS_LIST_H: f32 = 140.0;
|
||||
const ROWS_LIST_H: f32 = 280.0;
|
||||
|
||||
/// An entry of the file and what it becomes.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Row {
|
||||
/// As the file writes it.
|
||||
pub name: String,
|
||||
/// The names of the blocks around it (`Menus › Edit`).
|
||||
pub section: String,
|
||||
pub line: usize,
|
||||
pub key: Option<i64>,
|
||||
pub modifiers: Option<i64>,
|
||||
/// A key of [`shortcut_editor::entries`], or "" for no equivalent; none until chosen.
|
||||
pub target: Option<String>,
|
||||
/// Where the target came from: `saved` (an earlier import), `matched` (the name) or `you`.
|
||||
pub from: String,
|
||||
/// The candidates to choose from when the name matched none clearly, best first.
|
||||
pub choices: Vec<String>,
|
||||
/// Checked: Apply assigns it.
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
/// What a key or modifier code stands for.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Code {
|
||||
/// A key, or modifiers, as Edit › Keyboard Shortcuts writes them: "" for a key with no equivalent
|
||||
/// or for no modifiers. None until known.
|
||||
pub text: Option<String>,
|
||||
/// `saved` (an earlier import), `described` (the format's description), `agreement` or `you`; ""
|
||||
/// while unknown.
|
||||
pub from: String,
|
||||
/// For `agreement`: the entries whose default shortcut agrees.
|
||||
pub votes: usize,
|
||||
/// The first few entries that use it.
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
/// What happens to a row on Apply.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Ready,
|
||||
/// Unchecked.
|
||||
Off,
|
||||
NeedsChoice,
|
||||
NeedsKey,
|
||||
NoEquivalent,
|
||||
/// A tool's shortcut with Cmd, Alt or Ctrl.
|
||||
ToolKey,
|
||||
/// A later row takes the same shortcut.
|
||||
SameShortcut,
|
||||
/// A later row gives the same tool, command or panel its shortcut.
|
||||
SameTarget,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ready => tl!("Ready"),
|
||||
Self::Off => tl!("Off"),
|
||||
Self::NeedsChoice => tl!("Needs a choice"),
|
||||
Self::NeedsKey => tl!("Needs a key"),
|
||||
Self::NoEquivalent => tl!("No equivalent"),
|
||||
Self::ToolKey => tl!("Tools take a single key (optionally with Shift)"),
|
||||
Self::SameShortcut => tl!("A later row takes this shortcut"),
|
||||
Self::SameTarget => tl!("Same item as a later row"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user has something to answer or change.
|
||||
pub fn needs_attention(self) -> bool {
|
||||
matches!(self, Self::NeedsChoice | Self::NeedsKey | Self::ToolKey | Self::SameShortcut | Self::SameTarget)
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows and codes of a review.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Review {
|
||||
pub rows: Vec<Row>,
|
||||
pub keys: BTreeMap<i64, Code>,
|
||||
pub mods: BTreeMap<i64, Code>,
|
||||
}
|
||||
|
||||
impl Review {
|
||||
/// The review of `read`, `shortcutSet.read`'s result, with names matched against `candidates`
|
||||
/// ([`candidates`]).
|
||||
pub fn build(read: &Value, candidates: &[Candidate]) -> Self {
|
||||
let matcher = Matcher::new(candidates);
|
||||
let id = |i: usize| candidates.get(i).map(|c| c.id.clone());
|
||||
let mut rows = vec![];
|
||||
for e in read["entries"].as_array().into_iter().flatten() {
|
||||
let name = e["name"].as_str().unwrap_or_default().to_string();
|
||||
let (target, from, choices) = match e["target"].as_str() {
|
||||
Some(t) => (Some(t.to_string()), "saved", vec![]),
|
||||
None => match matcher.propose(&name) {
|
||||
Proposal::Match(i, _) => (id(i), "matched", vec![]),
|
||||
Proposal::Choice(best) => (None, "", best.into_iter().filter_map(id).collect()),
|
||||
},
|
||||
};
|
||||
rows.push(Row {
|
||||
section: e["section"].as_array().into_iter().flatten().filter_map(Value::as_str).collect::<Vec<_>>().join(" › "),
|
||||
line: e["line"].as_u64().unwrap_or(0) as usize,
|
||||
key: e["key"].as_i64(),
|
||||
modifiers: e["modifiers"].as_i64(),
|
||||
target,
|
||||
from: from.into(),
|
||||
choices,
|
||||
on: true,
|
||||
name,
|
||||
});
|
||||
}
|
||||
let codes = |list: &Value, field: &str, described: &[(i64, &str)], of: fn(&Row) -> Option<i64>| -> BTreeMap<i64, Code> {
|
||||
let mut out = BTreeMap::new();
|
||||
for c in list.as_array().into_iter().flatten() {
|
||||
let Some(value) = c["value"].as_i64() else { continue };
|
||||
let names = rows.iter().filter(|r| of(r) == Some(value)).take(NAMES_SHOWN).map(|r| r.name.clone()).collect();
|
||||
let (text, from) = match (c[field].as_str(), described.iter().find(|(v, _)| *v == value)) {
|
||||
(Some(t), _) => (Some(t.to_string()), "saved"),
|
||||
(None, Some((_, t))) => (Some(t.to_string()), "described"),
|
||||
(None, None) => (None, ""),
|
||||
};
|
||||
out.insert(value, Code { text, from: from.into(), votes: 0, names });
|
||||
}
|
||||
out
|
||||
};
|
||||
let keys = codes(&read["codes"]["keys"], "key", DESCRIBED_KEYS, |r| r.key);
|
||||
let mods = codes(&read["codes"]["modifiers"], "mods", DESCRIBED_MODS, |r| r.modifiers);
|
||||
let mut review = Self { rows, keys, mods };
|
||||
review.decode();
|
||||
review
|
||||
}
|
||||
|
||||
/// The review `d` holds.
|
||||
pub fn of(d: &Dialog) -> Self {
|
||||
fn field<T: serde::de::DeserializeOwned + Default>(d: &Dialog, k: &str) -> T {
|
||||
d.fields.get(k).and_then(|v| T::deserialize(v).ok()).unwrap_or_default()
|
||||
}
|
||||
Self { rows: field(d, "rows"), keys: field(d, "keys"), mods: field(d, "mods") }
|
||||
}
|
||||
|
||||
fn store(&self, d: &mut Dialog) {
|
||||
d.fields.insert("rows".into(), serde_json::to_value(&self.rows).unwrap_or_else(|_| json!([])));
|
||||
d.fields.insert("keys".into(), serde_json::to_value(&self.keys).unwrap_or_else(|_| json!({})));
|
||||
d.fields.insert("mods".into(), serde_json::to_value(&self.mods).unwrap_or_else(|_| json!({})));
|
||||
}
|
||||
|
||||
/// Codes that no earlier import, description or user answered take what agreement with
|
||||
/// VectorCraft's default shortcuts proposes for them now.
|
||||
pub fn decode(&mut self) {
|
||||
let mut defaults: HashMap<&str, Option<(String, String)>> = HashMap::new();
|
||||
let mut keys: BTreeMap<i64, HashMap<String, usize>> = BTreeMap::new();
|
||||
let mut mods: BTreeMap<i64, HashMap<String, usize>> = BTreeMap::new();
|
||||
for r in &self.rows {
|
||||
let Some(t) = r.target.as_deref().filter(|t| !t.is_empty()) else { continue };
|
||||
let default =
|
||||
defaults.entry(t).or_insert_with(|| shortcut_editor::default_of(t).and_then(shortcut_editor::normalize).map(|c| split_chord(&c)));
|
||||
let Some((m, k)) = default else { continue };
|
||||
if let Some(code) = r.key {
|
||||
*keys.entry(code).or_default().entry(k.clone()).or_default() += 1;
|
||||
}
|
||||
if let Some(code) = r.modifiers {
|
||||
*mods.entry(code).or_default().entry(m.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
for (codes, votes) in [(&mut self.keys, keys), (&mut self.mods, mods)] {
|
||||
for (code, c) in codes.iter_mut().filter(|(_, c)| c.from.is_empty() || c.from == "agreement") {
|
||||
let names = std::mem::take(&mut c.names);
|
||||
*c = match votes.get(code).and_then(agreed) {
|
||||
Some((text, votes)) => Code { text: Some(text), from: "agreement".into(), votes, names },
|
||||
None => Code { names, ..Code::default() },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `r`'s shortcut as Edit › Keyboard Shortcuts writes it: `Some(None)` when its key has no
|
||||
/// equivalent, none while one of its codes isn't known. An entry without `/Modifiers` takes none.
|
||||
pub fn chord(&self, r: &Row) -> Option<Option<String>> {
|
||||
let key = self.keys.get(&r.key?)?.text.as_deref()?;
|
||||
// A key with no equivalent needs no modifiers.
|
||||
if key.is_empty() {
|
||||
return Some(None);
|
||||
}
|
||||
let mods = match r.modifiers {
|
||||
Some(m) => self.mods.get(&m)?.text.as_deref()?,
|
||||
None => "",
|
||||
};
|
||||
Some(shortcut_editor::normalize(&if mods.is_empty() { key.to_string() } else { format!("{mods}+{key}") }))
|
||||
}
|
||||
|
||||
fn status(&self, r: &Row) -> (Status, Option<String>) {
|
||||
if !r.on {
|
||||
return (Status::Off, None);
|
||||
}
|
||||
let target = match r.target.as_deref() {
|
||||
None => return (Status::NeedsChoice, None),
|
||||
Some("") => return (Status::NoEquivalent, None),
|
||||
Some(t) if !known(t) => return (Status::NeedsChoice, None),
|
||||
Some(t) => t,
|
||||
};
|
||||
match self.chord(r) {
|
||||
None => (Status::NeedsKey, None),
|
||||
Some(None) => (Status::NoEquivalent, None),
|
||||
Some(Some(c)) if target.starts_with("tool:") && !single_key(&c) => (Status::ToolKey, Some(c)),
|
||||
Some(Some(c)) => (Status::Ready, Some(c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each row's status, with the shortcut it takes. When ready rows give the same tool, command or
|
||||
/// panel a shortcut, or want the same shortcut, the last of them does.
|
||||
pub fn statuses(&self) -> Vec<(Status, Option<String>)> {
|
||||
let mut out: Vec<(Status, Option<String>)> = self.rows.iter().map(|r| self.status(r)).collect();
|
||||
let (mut targets, mut chords) = (HashSet::new(), HashSet::new());
|
||||
for (r, (status, chord)) in self.rows.iter().zip(out.iter_mut()).rev() {
|
||||
let (Status::Ready, Some(t), Some(c)) = (*status, r.target.as_deref(), chord.as_ref()) else { continue };
|
||||
if targets.contains(t) {
|
||||
*status = Status::SameTarget;
|
||||
} else if chords.contains(c) {
|
||||
*status = Status::SameShortcut;
|
||||
} else {
|
||||
targets.insert(t);
|
||||
chords.insert(c.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Some code the Keys section lists is not known yet.
|
||||
fn asks(&self) -> bool {
|
||||
let (keys, mods) = self.asked();
|
||||
keys.iter().any(|k| self.keys.get(k).is_some_and(|c| c.text.is_none()))
|
||||
|| mods.iter().any(|m| self.mods.get(m).is_some_and(|c| c.text.is_none()))
|
||||
}
|
||||
|
||||
/// The codes the Keys section lists: those of the checked rows with a target, the unknown ones
|
||||
/// first.
|
||||
fn asked(&self) -> (Vec<i64>, Vec<i64>) {
|
||||
let (mut keys, mut mods) = (BTreeSet::new(), BTreeSet::new());
|
||||
for r in self.rows.iter().filter(|r| r.on && r.target.as_deref().is_some_and(|t| !t.is_empty())) {
|
||||
keys.extend(r.key.filter(|k| self.keys.contains_key(k)));
|
||||
mods.extend(r.modifiers.filter(|m| self.mods.contains_key(m)));
|
||||
}
|
||||
let unknown_first = |set: BTreeSet<i64>, codes: &BTreeMap<i64, Code>| {
|
||||
let mut v: Vec<i64> = set.into_iter().collect();
|
||||
v.sort_by_key(|c| codes.get(c).is_some_and(|c| c.text.is_some()));
|
||||
v
|
||||
};
|
||||
(unknown_first(keys, &self.keys), unknown_first(mods, &self.mods))
|
||||
}
|
||||
}
|
||||
|
||||
/// The text most votes agree on, with its votes, when at least [`MIN_VOTES`] voted and four votes in
|
||||
/// five agree.
|
||||
fn agreed(votes: &HashMap<String, usize>) -> Option<(String, usize)> {
|
||||
let total: usize = votes.values().sum();
|
||||
let (text, n) = votes.iter().max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0)))?;
|
||||
(total >= MIN_VOTES && n * AGREE.1 >= total * AGREE.0).then(|| (text.clone(), *n))
|
||||
}
|
||||
|
||||
/// A shortcut as written (`Cmd+Shift+K`) split into its modifiers (`Cmd+Shift`, "" for none) and key.
|
||||
fn split_chord(chord: &str) -> (String, String) {
|
||||
match chord.rsplit_once('+') {
|
||||
Some((m, k)) => (m.to_string(), k.to_string()),
|
||||
None => (String::new(), chord.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A shortcut a tool can take: one key, optionally with Shift.
|
||||
fn single_key(chord: &str) -> bool {
|
||||
crate::shortcuts::parse(chord).is_some_and(|sc| !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl))
|
||||
}
|
||||
|
||||
/// What a name can stand for: Keyboard Shortcuts' entries, by their English labels and the labels in
|
||||
/// the interface language.
|
||||
pub fn candidates() -> Vec<Candidate> {
|
||||
shortcut_editor::entries()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let shown = tl!(&e.label);
|
||||
let labels = if shown == e.label { vec![e.label.clone()] } else { vec![e.label.clone(), shown.to_string()] };
|
||||
Candidate { id: e.key.clone(), labels }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keyboard Shortcuts' entry `key`.
|
||||
fn entry(key: &str) -> Option<&'static Entry> {
|
||||
static BY_KEY: OnceLock<HashMap<&'static str, &'static Entry>> = OnceLock::new();
|
||||
BY_KEY.get_or_init(|| shortcut_editor::entries().iter().map(|e| (e.key.as_str(), e)).collect()).get(key).copied()
|
||||
}
|
||||
|
||||
/// A tool, command or panel that Keyboard Shortcuts can give a shortcut.
|
||||
fn known(key: &str) -> bool {
|
||||
entry(key).is_some() || shortcut_editor::default_of(key).is_some()
|
||||
}
|
||||
|
||||
/// How Keyboard Shortcuts names entry `e`: a tool by its label, the others after their menu.
|
||||
fn shown(e: &Entry) -> String {
|
||||
if e.is_tool || e.group.is_empty() { tl!(&e.label).to_string() } else { format!("{} › {}", tl!(&e.group), tl!(&e.label)) }
|
||||
}
|
||||
|
||||
/// What a row's target shows.
|
||||
fn target_label(target: Option<&str>) -> String {
|
||||
match target {
|
||||
None => tl!("Choose…").to_string(),
|
||||
Some("") => tl!("No equivalent").to_string(),
|
||||
Some(t) => entry(t).map_or_else(|| t.to_string(), shown),
|
||||
}
|
||||
}
|
||||
|
||||
/// Modifiers as written (`Cmd+Shift`, "" for none) as the menus show them.
|
||||
fn pretty_mods(mods: &str) -> String {
|
||||
if mods.is_empty() {
|
||||
return tl!("None").to_string();
|
||||
}
|
||||
let shown = menus::pretty_shortcut(&format!("{mods}+K"));
|
||||
shown.strip_suffix("+K").or_else(|| shown.strip_suffix('K')).unwrap_or(&shown).to_string()
|
||||
}
|
||||
|
||||
/// Where a code's answer came from, as the Keys section says it.
|
||||
fn source(c: &Code) -> String {
|
||||
match c.from.as_str() {
|
||||
"saved" => tl!("from your earlier imports").to_string(),
|
||||
"described" => tl!("from the format's public description").to_string(),
|
||||
"agreement" => crate::i18n::tn(c.votes as u64, "agrees with {n} default shortcut", "agrees with {n} default shortcuts"),
|
||||
"you" => String::new(),
|
||||
_ => tl!("not known yet").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file named as a shortcut set another app saves (`.kys`).
|
||||
pub fn is_kys(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::shortcutset::EXTS.contains(&vectorcraft_engine::cmd::fileio::extension(path).as_str())
|
||||
}
|
||||
|
||||
/// A set another app saved as text: a `.kys` file, or text that starts with a slash name.
|
||||
pub fn is_text_set(path: &str, bytes: &[u8]) -> bool {
|
||||
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
is_kys(path) || text.trim_ascii_start().starts_with(b"/")
|
||||
}
|
||||
|
||||
/// Open the review of the shortcut set in `bytes`, a file called `name` (a path or a file name) →
|
||||
/// `{entries, unread}`. An open Keyboard Shortcuts dialog is kept to go back to.
|
||||
pub fn open(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<Value, String> {
|
||||
// Only the part `shortcutSet.read` takes in.
|
||||
let head = bytes.get(..vectorcraft_engine::cmd::shortcutset::READ_LIMIT).unwrap_or(bytes);
|
||||
open_read(app, json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(head) }))
|
||||
}
|
||||
|
||||
/// Open the review of the set `shortcutSet.read` reads with `params`.
|
||||
fn open_read(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
let read = app.run("shortcutSet.read", params)?;
|
||||
let review = Review::build(&read, &candidates());
|
||||
let back = app.ui.dialog.take().filter(|d| d.kind == "shortcuts");
|
||||
let mut d = Dialog::new(
|
||||
KIND,
|
||||
json!({
|
||||
"name": read["set"],
|
||||
"start": "defaults",
|
||||
"remember": true,
|
||||
"notes": read["unread"],
|
||||
"truncated": read["truncated"],
|
||||
super::PARENT: back.and_then(|b| serde_json::to_value(b).ok()),
|
||||
"__recording": "",
|
||||
"__query": "",
|
||||
"__attention": false,
|
||||
"__keysOpen": review.asks(),
|
||||
}),
|
||||
);
|
||||
review.store(&mut d);
|
||||
app.ui.dialog = Some(d);
|
||||
Ok(json!({ "entries": review.rows.len(), "unread": read["unread"].as_array().map_or(0, Vec::len) }))
|
||||
}
|
||||
|
||||
/// `ui.shortcutImportDialog`: the review of the file at `path`, or in `dataBase64` (called `name`),
|
||||
/// else of a picked one. On the web the browser's file picker opens, and File › Open opens the
|
||||
/// review of a `.kys`.
|
||||
pub fn command(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
if let Some(b64) = s("dataBase64") {
|
||||
return open_read(app, json!({ "name": s("name").unwrap_or_default(), "dataBase64": b64 }));
|
||||
}
|
||||
let path = match s("path") {
|
||||
Some(path) => path,
|
||||
None if app.services.open_async.is_some() => return crate::io::open_dialog(app).map(|_| Value::Null),
|
||||
None => {
|
||||
let pick = FilePick { filters: vec![("Shortcut sets", vectorcraft_engine::cmd::shortcutset::EXTS)], ..Default::default() };
|
||||
crate::picks::open(app, &pick).ok_or("cancelled")?
|
||||
}
|
||||
};
|
||||
open_read(app, json!({ "path": path }))
|
||||
}
|
||||
|
||||
/// Apply: the ready rows assigned in the file's order over VectorCraft's defaults (or the current
|
||||
/// shortcuts), and Keyboard Shortcuts shown with the result as its working set; its OK keeps it. With
|
||||
/// Remember My Choices on, the matches and codes used are kept for later imports (`importMatches.set`).
|
||||
/// → `{applied, rows}`
|
||||
pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let review = Review::of(d);
|
||||
let statuses = review.statuses();
|
||||
let back = super::parent(d);
|
||||
let mut ov: BTreeMap<String, String> = match (d.str("start") == "current", &back) {
|
||||
(true, Some(b)) => b.fields.get("overrides").and_then(|v| BTreeMap::deserialize(v).ok()).unwrap_or_default(),
|
||||
(true, None) => app.ui.shortcut_overrides.clone(),
|
||||
(false, _) => BTreeMap::new(),
|
||||
};
|
||||
let mut applied = 0usize;
|
||||
for (r, (status, chord)) in review.rows.iter().zip(&statuses) {
|
||||
if let (Status::Ready, Some(t), Some(c)) = (status, r.target.as_deref(), chord)
|
||||
&& shortcut_editor::assign(&mut ov, t, Some(c), true).is_ok()
|
||||
{
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
let remembered = if d.bool("remember") { remember(app, &review, &statuses) } else { Ok(()) };
|
||||
let name = d.str("name");
|
||||
let set = Some(name.trim()).filter(|n| !n.is_empty()).unwrap_or(shortcut_editor::CUSTOM).to_string();
|
||||
app.ui.dialog = back;
|
||||
if app.ui.dialog.as_ref().is_none_or(|b| b.kind != "shortcuts") {
|
||||
shortcut_editor::open(app);
|
||||
}
|
||||
if let Some(k) = app.ui.dialog.as_mut() {
|
||||
k.fields.insert("overrides".into(), serde_json::to_value(&ov).unwrap_or_else(|_| json!({})));
|
||||
k.fields.insert("set".into(), json!(set));
|
||||
k.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set.")));
|
||||
}
|
||||
if let Err(e) = remembered {
|
||||
app.status(e);
|
||||
}
|
||||
Ok(json!({ "applied": applied, "rows": review.rows.len() }))
|
||||
}
|
||||
|
||||
/// Keep for later imports the targets and codes the user chose, and those of the rows Apply assigns.
|
||||
fn remember(app: &mut VectorcraftApp, review: &Review, statuses: &[(Status, Option<String>)]) -> Result<(), String> {
|
||||
let mut matches = vec![];
|
||||
let (mut used_keys, mut used_mods) = (BTreeSet::new(), BTreeSet::new());
|
||||
for (r, (status, _)) in review.rows.iter().zip(statuses) {
|
||||
let ready = *status == Status::Ready;
|
||||
if let Some(t) = &r.target
|
||||
&& r.from != "saved"
|
||||
&& (ready || r.from == "you")
|
||||
{
|
||||
let mut m = json!({ "kind": "name", "name": r.name, "target": t });
|
||||
if let Some(e) = entry(t) {
|
||||
m["label"] = json!(e.label);
|
||||
}
|
||||
matches.push(m);
|
||||
}
|
||||
if ready {
|
||||
used_keys.extend(r.key);
|
||||
used_mods.extend(r.modifiers);
|
||||
}
|
||||
}
|
||||
for (kind, codes, used) in [("key", &review.keys, &used_keys), ("modifiers", &review.mods, &used_mods)] {
|
||||
for (code, c) in codes {
|
||||
if let Some(text) = &c.text
|
||||
&& (c.from == "you" || (c.from == "agreement" && used.contains(code)))
|
||||
{
|
||||
matches.push(json!({ "kind": kind, "name": code.to_string(), "target": text }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches.is_empty() {
|
||||
app.run("importMatches.set", json!({ "matches": matches }))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rows Apply leaves out and the parts of the file not read, a line each, after the set's name.
|
||||
pub fn report(d: &Dialog, review: &Review, statuses: &[(Status, Option<String>)]) -> String {
|
||||
let line = |n: usize, name: &str, status: &str| {
|
||||
crate::i18n::fmt(tl!("Line {line}: {name}: {status}"), &[("line", &n.to_string()), ("name", name), ("status", status)])
|
||||
};
|
||||
let mut lines = vec![d.str("name")];
|
||||
for (r, (status, _)) in review.rows.iter().zip(statuses) {
|
||||
if *status != Status::Ready {
|
||||
lines.push(line(r.line, &r.name, status.label()));
|
||||
}
|
||||
}
|
||||
for n in d.fields.get("notes").and_then(Value::as_array).into_iter().flatten() {
|
||||
lines.push(line(n["line"].as_u64().unwrap_or(0) as usize, n["text"].as_str().unwrap_or_default(), tl!("Not read")));
|
||||
}
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
// ---------- dialog ----------
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let mut review = Review::of(&d);
|
||||
let before = review.clone();
|
||||
let t = Tokens::get(ctx);
|
||||
// A key press answers the key code being recorded, before the widgets see it; Escape stops.
|
||||
if let Ok(code) = d.str("__recording").parse::<i64>() {
|
||||
let ev = ctx.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, .. } if !crate::shortcuts::is_modifier(*key) => Some(*key),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(key) = ev {
|
||||
ctx.input_mut(|i| i.events.clear());
|
||||
d.fields.insert("__recording".into(), json!(""));
|
||||
if key != egui::Key::Escape
|
||||
&& let Some(text) = shortcut_editor::chord_from_event(key, egui::Modifiers::NONE)
|
||||
&& let Some(c) = review.keys.get_mut(&code)
|
||||
{
|
||||
c.text = Some(text);
|
||||
c.from = "you".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
let statuses = review.statuses();
|
||||
// The Keys and Shortcuts lists get the window's height less the rest of the dialog, as the frame
|
||||
// before drew it.
|
||||
let id = egui::Id::new("dialog-shortcut-import");
|
||||
let rest = ctx.data(|m| m.get_temp::<f32>(id.with("rest"))).unwrap_or(0.0);
|
||||
let (keys, mods) = review.asked();
|
||||
let keys_open = d.bool("__keysOpen") && (!keys.is_empty() || !mods.is_empty());
|
||||
let (keys_h, rows_h) = list_heights(ctx.content_rect().height() - rest, keys_open);
|
||||
let (mut apply, mut cancel, mut copy, mut lists) = (false, false, false, 0.0);
|
||||
crate::dialogs::modal::show(ctx, tl!("Import Shortcut Set"), id, -20.0, 20, |ui| {
|
||||
ui.set_width(NAME_W + TARGET_W + CHORD_W + 220.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Import Shortcut Set"));
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
|
||||
let mut name = d.str("name");
|
||||
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)).changed() {
|
||||
d.fields.insert("name".into(), json!(name));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
egui::RichText::new(tl!(
|
||||
"Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left."
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.size(11.5),
|
||||
);
|
||||
lists = keys_section(ui, &mut d, &mut review, keys_h) + rows_section(ui, &mut d, &mut review, &statuses, rows_h);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let current = d.str("start") == "current";
|
||||
if widgets::radio(ui, tl!("Start from VectorCraft Defaults"), !current, true) {
|
||||
d.fields.insert("start".into(), json!("defaults"));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
if widgets::radio(ui, tl!("Add to the Current Shortcuts"), current, true) {
|
||||
d.fields.insert("start".into(), json!("current"));
|
||||
}
|
||||
});
|
||||
let remember = d.bool("remember");
|
||||
if widgets::check(ui, tl!("Remember My Choices"), remember, true) {
|
||||
d.fields.insert("remember".into(), json!(!remember));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let ready = statuses.iter().filter(|(s, _)| *s == Status::Ready).count();
|
||||
let mut summary =
|
||||
crate::i18n::fmt(tl!("{ready} of {total} ready"), &[("ready", &ready.to_string()), ("total", &review.rows.len().to_string())]);
|
||||
let notes = d.fields.get("notes").and_then(Value::as_array).map_or(0, Vec::len);
|
||||
if notes > 0 {
|
||||
summary.push_str(" · ");
|
||||
summary.push_str(&crate::i18n::tn(notes as u64, "{n} part of the file wasn't read", "{n} parts of the file weren't read"));
|
||||
}
|
||||
if d.bool("truncated") {
|
||||
summary.push_str(" · ");
|
||||
summary.push_str(tl!("The file is too large to read in full, so only its first part is listed."));
|
||||
}
|
||||
ui.label(egui::RichText::new(summary).color(t.text_dim).size(11.5));
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::secondary_button(ui, tl!("Copy Report")).clicked() {
|
||||
copy = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button(ui, tl!("Apply")).clicked() {
|
||||
apply = true;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(window) = ctx.memory(|m| m.area_rect(id)) {
|
||||
ctx.data_mut(|m| m.insert_temp(id.with("rest"), window.height() - lists));
|
||||
}
|
||||
if review != before {
|
||||
if review.rows.iter().map(|r| &r.target).ne(before.rows.iter().map(|r| &r.target)) {
|
||||
review.decode();
|
||||
}
|
||||
review.store(&mut d);
|
||||
}
|
||||
if copy {
|
||||
app.clipboard_out = Some(report(&d, &review, &review.statuses()));
|
||||
app.status(tl!("Report copied"));
|
||||
}
|
||||
app.ui.dialog = Some(d);
|
||||
if cancel {
|
||||
crate::dialogs::cancel(app);
|
||||
} else if apply && let Err(e) = crate::dialogs::confirm(app) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The heights of the Keys list (when `keys_open`) and the Shortcuts list when `room` points of the
|
||||
/// window are left for them: their tallest, or both shorter by the same share, down to a tenth.
|
||||
fn list_heights(room: f32, keys_open: bool) -> (f32, f32) {
|
||||
let tallest = if keys_open { KEYS_LIST_H + ROWS_LIST_H } else { ROWS_LIST_H };
|
||||
let share = (room / tallest).clamp(0.1, 1.0);
|
||||
(KEYS_LIST_H * share, ROWS_LIST_H * share)
|
||||
}
|
||||
|
||||
/// The Keys section: the codes to answer or check, each with a field for its key or modifiers, where
|
||||
/// its answer came from and the first entries that use it, in a list at most `height` points tall.
|
||||
/// Not shown when there are none. → the list's height (0 while the section is closed).
|
||||
fn keys_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, height: f32) -> f32 {
|
||||
let (keys, mods) = review.asked();
|
||||
if keys.is_empty() && mods.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(8.0);
|
||||
let open = d.bool("__keysOpen");
|
||||
if widgets::section_toggle(ui, tl!("Keys"), open) {
|
||||
d.fields.insert("__keysOpen".into(), json!(!open));
|
||||
}
|
||||
if !open {
|
||||
return 0.0;
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(tl!(
|
||||
"The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet."
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.size(11.5),
|
||||
);
|
||||
let recording = d.str("__recording");
|
||||
let list = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
scrolling_list("shortcut-import-keys", height).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for code in keys {
|
||||
let Some(c) = review.keys.get_mut(&code) else { continue };
|
||||
let waiting = recording == code.to_string();
|
||||
ui.horizontal(|ui| {
|
||||
cell(ui, 110.0, |ui| ui.label(egui::RichText::new(format!("/Key {code}")).color(t.text_dim)));
|
||||
let label = match (waiting, c.text.as_deref()) {
|
||||
(true, _) => tl!("Press a key…").to_string(),
|
||||
(false, None) => "?".to_string(),
|
||||
(false, Some("")) => tl!("No equivalent").to_string(),
|
||||
(false, Some(k)) => menus::pretty_shortcut(k),
|
||||
};
|
||||
let b = ui
|
||||
.add_sized([120.0, ROW_H], egui::Button::new(label).selected(waiting))
|
||||
.on_hover_text(tl!("Click, then press the key this code stands for (Esc cancels)"));
|
||||
if b.clicked() {
|
||||
d.fields.insert("__recording".into(), json!(code.to_string()));
|
||||
}
|
||||
if widgets::icon_button(ui, "x", tl!("No equivalent"), c.text.as_deref() == Some(""), 18.0).clicked() {
|
||||
c.text = Some(String::new());
|
||||
c.from = "you".into();
|
||||
}
|
||||
code_notes(ui, c);
|
||||
});
|
||||
}
|
||||
for code in mods {
|
||||
let Some(c) = review.mods.get_mut(&code) else { continue };
|
||||
ui.horizontal(|ui| {
|
||||
cell(ui, 110.0, |ui| ui.label(egui::RichText::new(format!("/Modifiers {code}")).color(t.text_dim)));
|
||||
let current = c.text.as_deref().map_or_else(|| "?".to_string(), pretty_mods);
|
||||
let options: Vec<String> = MODIFIERS.iter().map(|m| pretty_mods(m)).collect();
|
||||
let names: Vec<&str> = options.iter().map(String::as_str).collect();
|
||||
if let Some(m) =
|
||||
widgets::dropdown_names(ui, ("shortcut-import-mods", code), ¤t, &names, 144.0).and_then(|i| MODIFIERS.get(i))
|
||||
{
|
||||
c.text = Some(m.to_string());
|
||||
c.from = "you".into();
|
||||
}
|
||||
code_notes(ui, c);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
list.inner.inner_rect.height()
|
||||
}
|
||||
|
||||
/// A vertical list that scrolls when its contents are taller than `height` points, and is then
|
||||
/// `height` points tall, also under egui's 64-point minimum.
|
||||
fn scrolling_list(id: &str, height: f32) -> egui::ScrollArea {
|
||||
egui::ScrollArea::vertical().id_salt(id).max_height(height).min_scrolled_height(height)
|
||||
}
|
||||
|
||||
/// A cell `w` points wide with its contents on the left.
|
||||
fn cell<R>(ui: &mut egui::Ui, w: f32, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
ui.allocate_ui_with_layout(egui::vec2(w, ROW_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(w);
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// Where code `c`'s answer came from, and the first entries that use it.
|
||||
fn code_notes(ui: &mut egui::Ui, c: &Code) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let from = source(c);
|
||||
if !from.is_empty() {
|
||||
ui.label(egui::RichText::new(from).color(t.text_dim).size(11.0));
|
||||
}
|
||||
let names = c.names.join(", ");
|
||||
ui.add(egui::Label::new(egui::RichText::new(names).color(t.text_dim).size(11.0).italics()).truncate());
|
||||
}
|
||||
|
||||
/// The rows: a check box, the name as the file writes it, what it stands for in VectorCraft (a
|
||||
/// searchable picker), the shortcut and the status, in a list `height` points tall. → the list's
|
||||
/// height.
|
||||
fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses: &[(Status, Option<String>)], height: f32) -> f32 {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
widgets::section_header(ui, tl!("Shortcuts"));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let attention = d.bool("__attention");
|
||||
if widgets::check(ui, tl!("Needs Attention Only"), attention, true) {
|
||||
d.fields.insert("__attention".into(), json!(!attention));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let mut q = d.str("__query");
|
||||
if ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search")).desired_width(180.0)).changed() {
|
||||
d.fields.insert("__query".into(), json!(q));
|
||||
}
|
||||
});
|
||||
});
|
||||
let query = d.str("__query").to_lowercase();
|
||||
let attention = d.bool("__attention");
|
||||
let visible: Vec<usize> = review
|
||||
.rows
|
||||
.iter()
|
||||
.zip(statuses)
|
||||
.enumerate()
|
||||
.filter(|(_, (r, (status, _)))| {
|
||||
(!attention || status.needs_attention())
|
||||
&& (query.is_empty() || r.name.to_lowercase().contains(&query) || target_label(r.target.as_deref()).to_lowercase().contains(&query))
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let list = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(22.0);
|
||||
for (label, w) in [(tl!("In the File"), NAME_W), (tl!("In VectorCraft"), TARGET_W), (tl!("Shortcut"), CHORD_W)] {
|
||||
cell(ui, w, |ui| ui.label(egui::RichText::new(label).color(t.text_dim).size(11.0)));
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Status")).color(t.text_dim).size(11.0));
|
||||
});
|
||||
scrolling_list("shortcut-import-rows", height).auto_shrink([false, false]).show_rows(ui, ROW_H, visible.len(), |ui, range| {
|
||||
for i in range.filter_map(|v| visible.get(v).copied()) {
|
||||
let (status, chord) = statuses.get(i).cloned().unwrap_or((Status::NeedsChoice, None));
|
||||
let Some(r) = review.rows.get_mut(i) else { continue };
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::check(ui, "", r.on, true) {
|
||||
r.on = !r.on;
|
||||
}
|
||||
let place = crate::i18n::fmt(tl!("Line {line}"), &[("line", &r.line.to_string())]);
|
||||
let place = if r.section.is_empty() { place } else { format!("{} · {place}", r.section) };
|
||||
cell(ui, NAME_W, |ui| ui.add(egui::Label::new(egui::RichText::new(&r.name).color(t.text)).truncate()).on_hover_text(place));
|
||||
if let Some(target) = target_picker(ui, i, r) {
|
||||
r.target = Some(target);
|
||||
r.from = "you".into();
|
||||
}
|
||||
let shortcut = chord.as_deref().map_or_else(|| "—".to_string(), menus::pretty_shortcut);
|
||||
cell(ui, CHORD_W, |ui| ui.label(egui::RichText::new(shortcut).color(t.text)));
|
||||
let color = match status {
|
||||
Status::Ready => t.text,
|
||||
Status::Off | Status::NoEquivalent => t.text_dim,
|
||||
_ => t.warning,
|
||||
};
|
||||
ui.add(egui::Label::new(egui::RichText::new(status.label()).color(color).size(11.5)).truncate());
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
list.inner.inner_rect.height()
|
||||
}
|
||||
|
||||
/// Row `r`'s target picker: "No equivalent" and the row's choices, then Keyboard Shortcuts' entries
|
||||
/// that match the search. → the target picked ("" for no equivalent).
|
||||
fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option<String> {
|
||||
let current = target_label(r.target.as_deref());
|
||||
widgets::combo(ui, ("shortcut-import-target", i), ¤t, TARGET_W, true, |ui| {
|
||||
let field = ui.id().with("query");
|
||||
if ui.memory(|m| m.focused().is_none()) {
|
||||
ui.memory_mut(|m| m.request_focus(field.with("edit")));
|
||||
}
|
||||
let q = widgets::search_field(ui, field, tl!("Search")).to_lowercase();
|
||||
let mut picked = None;
|
||||
let mut option = |ui: &mut egui::Ui, key: &str, label: &str| {
|
||||
if ui.add(egui::Button::selectable(r.target.as_deref() == Some(key), label)).clicked() {
|
||||
picked = Some(key.to_string());
|
||||
}
|
||||
};
|
||||
option(ui, "", tl!("No equivalent"));
|
||||
let list: Vec<&Entry> = if q.is_empty() {
|
||||
for e in r.choices.iter().filter_map(|c| entry(c)) {
|
||||
option(ui, &e.key, &shown(e));
|
||||
}
|
||||
ui.separator();
|
||||
shortcut_editor::entries().iter().collect()
|
||||
} else {
|
||||
shortcut_editor::entries().iter().filter(|e| shown(e).to_lowercase().contains(&q) || e.key.to_lowercase().contains(&q)).collect()
|
||||
};
|
||||
egui::ScrollArea::vertical().max_height(300.0).show_rows(ui, ROW_H, list.len(), |ui, range| {
|
||||
for e in range.filter_map(|k| list.get(k)) {
|
||||
option(ui, &e.key, &shown(e));
|
||||
}
|
||||
});
|
||||
if picked.is_some() {
|
||||
ui.close();
|
||||
}
|
||||
picked
|
||||
})
|
||||
}
|
||||
@@ -95,7 +95,10 @@ fn the_web_downloads_a_zip() {
|
||||
let mut app = saved(&dir);
|
||||
let got: Rc<RefCell<Vec<(String, usize)>>> = Rc::default();
|
||||
let g = got.clone();
|
||||
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| g.borrow_mut().push((name.to_string(), b.len()))));
|
||||
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| {
|
||||
g.borrow_mut().push((name.to_string(), b.len()));
|
||||
Ok(())
|
||||
}));
|
||||
app.run("ui.packageDialog", json!({})).unwrap();
|
||||
assert!(!frame(&mut app).contains("Location:"), "no folder to choose");
|
||||
confirm(&mut app).unwrap();
|
||||
@@ -124,7 +127,7 @@ fn fonts_left_out_of_the_package_are_named() {
|
||||
assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}");
|
||||
// The web's download says so too.
|
||||
app.ui.dialog = None;
|
||||
app.services.download = Some(Box::new(|_: &str, _: &[u8]| {}));
|
||||
app.services.download = Some(Box::new(|_: &str, _: &[u8]| Ok(())));
|
||||
app.run("ui.packageDialog", json!({})).unwrap();
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.status.contains(gone), "{}", app.ui.status);
|
||||
|
||||
@@ -24,7 +24,10 @@ fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) {
|
||||
..Default::default()
|
||||
};
|
||||
if web {
|
||||
services.download = Some(Box::new(move |name: &str, b: &[u8]| l2.borrow_mut().push(format!("download {name} {}", b.len()))));
|
||||
services.download = Some(Box::new(move |name: &str, b: &[u8]| {
|
||||
l2.borrow_mut().push(format!("download {name} {}", b.len()));
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 60, "height": 40})).unwrap();
|
||||
|
||||
@@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
|
||||
let l = log.clone();
|
||||
let mut services = Services::default();
|
||||
if web {
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
|
||||
l.borrow_mut().push((name.to_string(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}));
|
||||
} else {
|
||||
services.reveal = Some(Box::new(move |path: &str| {
|
||||
l.borrow_mut().push((path.to_string(), vec![]));
|
||||
|
||||
@@ -21,7 +21,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) {
|
||||
let l = log.clone();
|
||||
let mut services = Services::default();
|
||||
if web {
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
|
||||
l.borrow_mut().push((name.to_string(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}));
|
||||
} else {
|
||||
services.reveal = Some(Box::new(move |path: &str| {
|
||||
l.borrow_mut().push((path.to_string(), vec![]));
|
||||
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user