mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Compare commits
60
Commits
a699a26221
...
52960faf63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52960faf63 | ||
|
|
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 |
No files matched your search
Generated
+36
-17
@@ -809,7 +809,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f39922b214dc429bcf6551ef71ed3631337b2acf#f39922b214dc429bcf6551ef71ed3631337b2acf"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1313,6 +1313,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"
|
||||
@@ -1769,9 +1775,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"
|
||||
@@ -1785,9 +1791,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",
|
||||
@@ -1797,28 +1803,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]]
|
||||
@@ -1829,9 +1835,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",
|
||||
@@ -2230,7 +2236,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f39922b214dc429bcf6551ef71ed3631337b2acf#f39922b214dc429bcf6551ef71ed3631337b2acf"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3841,6 +3847,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"
|
||||
@@ -4776,6 +4792,7 @@ dependencies = [
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"krilla",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
@@ -4792,6 +4809,7 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4967,6 +4985,7 @@ dependencies = [
|
||||
"vectorcraft-three-d",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4994,7 +5013,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",
|
||||
@@ -5009,7 +5028,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",
|
||||
|
||||
+5
-2
@@ -11,7 +11,7 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "f39922b214dc429bcf6551ef71ed3631337b2acf", default-features = false, features = ["write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
@@ -62,6 +62,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"
|
||||
# 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"
|
||||
@@ -111,4 +114,4 @@ lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "f39922b214dc429bcf6551ef71ed3631337b2acf" }
|
||||
+4
-1
@@ -8,7 +8,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 (PDF.W1: guarded shared PDF writer; output behavior, scores and estimates 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; 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
|
||||
@@ -131,6 +131,8 @@ 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
|
||||
@@ -168,6 +170,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. |
|
||||
|
||||
@@ -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\"}");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -226,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(),
|
||||
_ => &[],
|
||||
@@ -235,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 [],
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -265,7 +265,7 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// 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.
|
||||
fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
if typing {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -457,6 +457,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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
@@ -2157,10 +2169,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 +2550,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 +2933,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 }
|
||||
craft-pdf = { 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"] }
|
||||
@@ -1330,7 +1330,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -753,3 +753,46 @@ fn empty_and_populated_file_info_preserve_key_presence() {
|
||||
}
|
||||
assert!(s.contains("Synthetic title") && s.contains("Test author") && s.contains("test keyword"));
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -366,6 +366,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"));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![]));
|
||||
|
||||
@@ -3508,6 +3508,8 @@ effect Regular Pravidelný
|
||||
Serif / Mincho Patkové / mincho
|
||||
Changes made since it was last saved will be lost. Změny provedené od posledního uložení budou ztraceny.
|
||||
Can't open “{name}”. „{name}“ nelze otevřít.
|
||||
Can't save “{name}”. „{name}“ nelze uložit.
|
||||
Can't export “{name}”. „{name}“ nelze exportovat.
|
||||
Downloaded {what} Staženo: {what}
|
||||
Exported {count} file(s) to {folder} Exportováno souborů: {count} do {folder}
|
||||
Exported {what} Exportováno: {what}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -754,6 +754,8 @@
|
||||
Do Not Move Nicht verschieben
|
||||
Do you want to save the changes you made to “{name}”? Möchten Sie die Änderungen an „{name}“ speichern?
|
||||
Can't open “{name}”. „{name}“ kann nicht geöffnet werden.
|
||||
Can't save “{name}”. „{name}“ kann nicht gespeichert werden.
|
||||
Can't export “{name}”. „{name}“ kann nicht exportiert werden.
|
||||
Document Dokument
|
||||
Document Color Mode Dokumentfarbmodus
|
||||
Document Info Dokumentinformationen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -768,6 +768,8 @@
|
||||
Do Not Move No mover
|
||||
Do you want to save the changes you made to “{name}”? ¿Quieres guardar los cambios hechos en “{name}”?
|
||||
Can't open “{name}”. No se puede abrir «{name}».
|
||||
Can't save “{name}”. No se puede guardar «{name}».
|
||||
Can't export “{name}”. No se puede exportar «{name}».
|
||||
Document Documento
|
||||
Document Color Mode Modo de color del documento
|
||||
Document Info Información del documento
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -770,6 +770,8 @@
|
||||
Do Not Move Ne pas déplacer
|
||||
Do you want to save the changes you made to “{name}”? Voulez-vous enregistrer les modifications apportées à « {name} » ?
|
||||
Can't open “{name}”. Impossible d’ouvrir « {name} ».
|
||||
Can't save “{name}”. Impossible d’enregistrer « {name} ».
|
||||
Can't export “{name}”. Impossible d’exporter « {name} ».
|
||||
Document Document
|
||||
Document Color Mode Mode colorimétrique du document
|
||||
Document Info Informations sur le document
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -485,6 +485,8 @@
|
||||
Calligraphic Kalligrafikus
|
||||
Calligraphic Brush Options Kalligrafikus ecset beállításai
|
||||
Can't open “{name}”. Nem nyitható meg: „{name}”.
|
||||
Can't save “{name}”. Nem menthető: „{name}”.
|
||||
Can't export “{name}”. Nem exportálható: „{name}”.
|
||||
Can't print these settings: see the Summary Ezekkel a beállításokkal nem lehet nyomtatni: lásd az Összegzést
|
||||
Cancel Mégse
|
||||
Cancel Key Object Kulcsobjektum megszüntetése
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -768,6 +768,8 @@
|
||||
Do Not Move Non spostare
|
||||
Do you want to save the changes you made to “{name}”? Vuoi salvare le modifiche apportate a “{name}”?
|
||||
Can't open “{name}”. Impossibile aprire «{name}».
|
||||
Can't save “{name}”. Impossibile salvare «{name}».
|
||||
Can't export “{name}”. Impossibile esportare «{name}».
|
||||
Document Documento
|
||||
Document Color Mode Metodo colore documento
|
||||
Document Info Informazioni documento
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1223,6 +1223,8 @@
|
||||
Divide Vertically Into 垂直方向に分割
|
||||
Do you want to save the changes you made to “{name}”? 「{name}」への変更を保存しますか?
|
||||
Can't open “{name}”. 「{name}」を開けません。
|
||||
Can't save “{name}”. 「{name}」を保存できません。
|
||||
Can't export “{name}”. 「{name}」を書き出せません。
|
||||
Document ドキュメント
|
||||
Document Ink Options ドキュメントのインキオプション
|
||||
Document JSON ドキュメント JSON
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1218,6 +1218,8 @@
|
||||
Divide Vertically Into 세로로 나누기
|
||||
Do you want to save the changes you made to “{name}”? “{name}”의 변경 내용을 저장하시겠습니까?
|
||||
Can't open “{name}”. “{name}”을(를) 열 수 없습니다.
|
||||
Can't save “{name}”. “{name}”을(를) 저장할 수 없습니다.
|
||||
Can't export “{name}”. “{name}”을(를) 내보낼 수 없습니다.
|
||||
Document 문서
|
||||
Document Ink Options 문서 잉크 옵션
|
||||
Document JSON 문서 JSON
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3391,6 +3391,8 @@ effect Regular Normalny
|
||||
Serif / Mincho Szeryfowe / mincho
|
||||
Changes made since it was last saved will be lost. Zmiany wprowadzone od ostatniego zapisu zostaną utracone.
|
||||
Can't open “{name}”. Nie można otworzyć „{name}”.
|
||||
Can't save “{name}”. Nie można zapisać „{name}”.
|
||||
Can't export “{name}”. Nie można wyeksportować „{name}”.
|
||||
Downloaded {what} Pobrano {what}
|
||||
Exported {count} file(s) to {folder} Wyeksportowane pliki do {folder}: {count}
|
||||
Exported {what} Wyeksportowano {what}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -692,6 +692,8 @@
|
||||
Divide Vertically Into Dividir Verticalmente Em
|
||||
Do you want to save the changes you made to “{name}”? Deseja salvar as alterações feitas em “{name}”?
|
||||
Can't open “{name}”. Não é possível abrir “{name}”.
|
||||
Can't save “{name}”. Não é possível salvar “{name}”.
|
||||
Can't export “{name}”. Não é possível exportar “{name}”.
|
||||
Document Documento
|
||||
Document Color Mode Modo de Cor do Documento
|
||||
Document Info Informações do Documento
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -766,6 +766,8 @@
|
||||
Do Not Move Не перемещать
|
||||
Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»?
|
||||
Can't open “{name}”. Не удаётся открыть «{name}».
|
||||
Can't save “{name}”. Не удаётся сохранить «{name}».
|
||||
Can't export “{name}”. Не удаётся экспортировать «{name}».
|
||||
Document Документ
|
||||
Document Color Mode Цветовой режим документа
|
||||
Document Info Сведения о документе
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -762,6 +762,8 @@
|
||||
Do Not Move Не переміщати
|
||||
Do you want to save the changes you made to “{name}”? Зберегти зміни, внесені до «{name}»?
|
||||
Can't open “{name}”. Не вдається відкрити «{name}».
|
||||
Can't save “{name}”. Не вдається зберегти «{name}».
|
||||
Can't export “{name}”. Не вдається експортувати «{name}».
|
||||
Document Документ
|
||||
Document Color Mode Колірний режим документа
|
||||
Document Info Відомості про документ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 713 and column 40.
|
@@ -401,6 +401,8 @@
|
||||
Calligraphic Thư pháp
|
||||
Calligraphic Brush Options Tùy chọn cọ thư pháp
|
||||
Can't open “{name}”. Không thể mở “{name}”.
|
||||
Can't save “{name}”. Không thể lưu “{name}”.
|
||||
Can't export “{name}”. Không thể xuất “{name}”.
|
||||
Can't print these settings: see the Summary Không thể in các cài đặt này: xem Tóm tắt
|
||||
Cancel Hủy
|
||||
Cancel Key Object Hủy đối tượng chính
|
||||
|
||||
|
@@ -723,6 +723,8 @@
|
||||
Do Not Move 不移动
|
||||
Do you want to save the changes you made to “{name}”? 要存储对“{name}”所做的更改吗?
|
||||
Can't open “{name}”. 无法打开“{name}”。
|
||||
Can't save “{name}”. 无法保存“{name}”。
|
||||
Can't export “{name}”. 无法导出“{name}”。
|
||||
Document 文档
|
||||
Document Color Mode 文档颜色模式
|
||||
Document Info 文档信息
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -741,6 +741,8 @@
|
||||
Do Not Move 不移動
|
||||
Do you want to save the changes you made to “{name}”? 要儲存對「{name}」所做的變更嗎?
|
||||
Can't open “{name}”. 無法開啟「{name}」。
|
||||
Can't save “{name}”. 無法儲存「{name}」。
|
||||
Can't export “{name}”. 無法匯出「{name}」。
|
||||
Document 文件
|
||||
Document Color Mode 文件色彩模式
|
||||
Document Info 文件資訊
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+118
-8
@@ -167,6 +167,29 @@ pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) {
|
||||
dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e));
|
||||
}
|
||||
|
||||
/// A save or an export that didn't write `path`: say so in a dialog that names the file and the
|
||||
/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which
|
||||
/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved
|
||||
/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request
|
||||
/// an agent sent and waits on: the error is its reply.
|
||||
pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) {
|
||||
if app.ui.answering_control {
|
||||
return;
|
||||
}
|
||||
let name = fileio::file_name(path);
|
||||
let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") };
|
||||
dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e));
|
||||
}
|
||||
|
||||
/// [`write_to`], a failure reported ([`report_write_error`]).
|
||||
pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let r = write_to(&mut app.services, path, bytes);
|
||||
if let Err(e) = &r {
|
||||
report_write_error(app, false, path, e);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On
|
||||
/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then
|
||||
/// `{background: true, status}`, and the document opens in its tab (or the failure is reported)
|
||||
@@ -239,8 +262,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Resu
|
||||
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
|
||||
pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dl) = services.download.as_mut() {
|
||||
dl(&fileio::file_name(path), bytes);
|
||||
return Ok(());
|
||||
return dl(&fileio::file_name(path), bytes);
|
||||
}
|
||||
services.write.as_mut().ok_or("no file writer")?(path, bytes)
|
||||
}
|
||||
@@ -260,7 +282,7 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
|
||||
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })?
|
||||
}
|
||||
};
|
||||
write_to(&mut app.services, &path, bytes)?;
|
||||
write_reporting(app, &path, bytes)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -516,8 +538,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String>
|
||||
}
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
let failed = target.clone().unwrap_or_default();
|
||||
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
|
||||
let r = r?;
|
||||
// Not written: the document keeps its file, format and modified state, and the user is told.
|
||||
let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?;
|
||||
// The document may have closed meanwhile: the file is written all the same.
|
||||
done.complete(&mut app.session, uid);
|
||||
let path = r["path"].as_str().unwrap_or_default().to_string();
|
||||
@@ -602,8 +626,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
Ok(out)
|
||||
};
|
||||
let enabled = app.session.prefs.background_export;
|
||||
let mut r = background::run(app, enabled, label, None, work, |app, r| {
|
||||
let r = r?;
|
||||
let failed = target.clone();
|
||||
let mut r = background::run(app, enabled, label, None, work, move |app, r| {
|
||||
let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?;
|
||||
let what = match r["files"].as_array() {
|
||||
Some(files) => format!("{} files", files.len()),
|
||||
None => r["path"].as_str().unwrap_or_default().to_string(),
|
||||
@@ -673,7 +698,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par
|
||||
Some(p) => p,
|
||||
None => target_path(app, None, ext)?,
|
||||
};
|
||||
write_to(&mut app.services, &path, &bytes)?;
|
||||
write_reporting(app, &path, &bytes)?;
|
||||
Ok((path, v))
|
||||
}
|
||||
|
||||
@@ -768,7 +793,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) ->
|
||||
} else if let Some(download) = app.services.download.as_mut() {
|
||||
for (name, data) in &returned {
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?;
|
||||
download(name, &bytes);
|
||||
download(name, &bytes)?;
|
||||
}
|
||||
let what = match returned.as_slice() {
|
||||
[(name, _)] => (*name).to_string(),
|
||||
@@ -830,6 +855,91 @@ mod tests {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// #1685: a save or an export that can't write its file says so in a dialog that names the
|
||||
/// file and the reason and stays until it is closed; the document stays modified and keeps
|
||||
/// the file it had. An agent's request only gets the error, and a message that arrives while
|
||||
/// another dialog is open waits for it to close.
|
||||
#[test]
|
||||
fn a_save_that_cannot_write_is_reported_in_a_dialog_that_stays() {
|
||||
let refuse: crate::WriteFn = Box::new(|p: &str, _: &[u8]| Err(format!("{p}: Access is denied. (os error 5)")));
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services { write: Some(refuse), ..Default::default() });
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
let unsaved = |app: &VectorcraftApp| {
|
||||
let st = app.session.doc().unwrap();
|
||||
(st.is_dirty(), st.path.clone())
|
||||
};
|
||||
for (command, path, message) in [
|
||||
("file.save", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."),
|
||||
("file.saveAs", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."),
|
||||
("file.saveCopy", "/docs/Copy.vectorcraft", "Can't save “Copy.vectorcraft”."),
|
||||
("file.exportAs", "/docs/Poster.png", "Can't export “Poster.png”."),
|
||||
("file.export.pdf", "/docs/Poster.pdf", "Can't save “Poster.pdf”."),
|
||||
] {
|
||||
let e = app.run(command, json!({"path": path})).unwrap_err();
|
||||
assert!(e.contains("Access is denied"), "{command}: {e}");
|
||||
let d = app.ui.dialog.take().unwrap_or_else(|| panic!("{command}: a dialog says so"));
|
||||
assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::MESSAGE, message), "{command}");
|
||||
assert_eq!(d.str("detail"), format!("{path}: Access is denied. (os error 5)"), "{command}");
|
||||
assert_eq!(unsaved(&app), (true, None), "{command}");
|
||||
}
|
||||
// An agent waiting on its request gets the error as its reply, and no dialog.
|
||||
app.ui.answering_control = true;
|
||||
assert!(app.run("file.save", json!({"path": "/docs/Poster.vectorcraft"})).is_err());
|
||||
app.ui.answering_control = false;
|
||||
assert!(app.ui.dialog.is_none() && app.ui.told.is_empty());
|
||||
// Under another dialog (a background save that fails later) the message waits its turn,
|
||||
// once, and is drawn when that dialog is closed.
|
||||
dialogs::confirm::ask(&mut app, "Delete the swatch?", "", "edit.undo", json!({}));
|
||||
for _ in 0..2 {
|
||||
report_write_error(&mut app, false, "/docs/Poster.vectorcraft", "the disk is full");
|
||||
}
|
||||
assert_eq!((app.ui.dialog.as_ref().map(|d| d.kind.as_str()), app.ui.told.len()), (Some(dialogs::confirm::KIND), 1));
|
||||
app.ui.dialog = None;
|
||||
for _ in 0..2 {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let d = app.ui.dialog.as_ref().expect("the message is shown, and stays");
|
||||
assert_eq!(
|
||||
(d.kind.as_str(), d.str("message").as_str(), d.str("detail").as_str()),
|
||||
(dialogs::confirm::MESSAGE, "Can't save “Poster.vectorcraft”.", "the disk is full")
|
||||
);
|
||||
assert!(app.ui.told.is_empty());
|
||||
}
|
||||
|
||||
/// #1693: a browser download that can't be started is a failed save: the document stays
|
||||
/// modified and without a file, and Save Changes doesn't close it.
|
||||
#[test]
|
||||
fn a_download_that_cannot_start_leaves_the_document_unsaved_and_open() {
|
||||
let started = Rc::new(RefCell::new(false));
|
||||
let ok = started.clone();
|
||||
let download: crate::DownloadFn =
|
||||
Box::new(move |_: &str, _: &[u8]| if *ok.borrow() { Ok(()) } else { Err("the browser refused the download".into()) });
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() });
|
||||
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
let unsaved = |app: &VectorcraftApp| {
|
||||
let st = app.session.doc().unwrap();
|
||||
(st.is_dirty(), st.path.clone())
|
||||
};
|
||||
let e = save(&mut app, SaveMode::Save, &json!({"path": "Poster.vectorcraft"}), false).unwrap_err();
|
||||
assert!(e.contains("the browser refused the download"), "{e}");
|
||||
assert_eq!(unsaved(&app), (true, None));
|
||||
// Save Changes › Save: not saved, so not closed.
|
||||
let (i, uid) = (app.session.active_index().unwrap(), app.session.doc().unwrap().uid);
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": i, "uid": uid, "name": "Poster", "then": "close"})));
|
||||
app.session.doc_mut().unwrap().path = Some("Poster.vectorcraft".into());
|
||||
assert!(crate::unsaved::confirm(&mut app).is_err());
|
||||
assert_eq!((app.session.documents().len(), app.session.doc().unwrap().is_dirty()), (1, true));
|
||||
// Once the download starts, the save counts.
|
||||
*started.borrow_mut() = true;
|
||||
save(&mut app, SaveMode::Save, &json!({}), false).unwrap();
|
||||
assert!(!app.session.doc().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
|
||||
let v = app.session.execute(cmd, &p).unwrap();
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
|
||||
@@ -162,7 +162,7 @@ use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_engine::{Session, ViewInfo};
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket};
|
||||
pub use state::{UiState, View};
|
||||
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
|
||||
|
||||
@@ -188,7 +188,9 @@ pub type PickSave = Box<dyn FnMut(&FilePick) -> Option<String>>;
|
||||
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
|
||||
/// Starts a browser download of the bytes under a file name; an error when it couldn't be started
|
||||
/// (the save or export then fails as a refused write does: #1693).
|
||||
pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
|
||||
/// Opens a URL in the system browser.
|
||||
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
|
||||
@@ -282,8 +284,8 @@ pub struct CanvasCache {
|
||||
pub slices: Option<SliceCache>,
|
||||
/// The print tiling's pages for (document uid, revision) (View → Show Print Tiling).
|
||||
pub print_tiling: Option<PrintTilingCache>,
|
||||
/// The marks Type → Show Hidden Characters drew on each text object in the last frame that
|
||||
/// showed them.
|
||||
/// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame
|
||||
/// that does not draw them drops them ([`canvas::show`]).
|
||||
pub hidden_chars: canvas::HiddenChars,
|
||||
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
|
||||
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
|
||||
@@ -769,11 +771,19 @@ impl VectorcraftApp {
|
||||
let mut handled = 0;
|
||||
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
|
||||
let Ok(req) = rx.try_recv() else { break };
|
||||
// Its caller stopped waiting (the app was busy or its window not drawn for too long)
|
||||
// and was told it won't run: an edit applied now would come from nowhere (#1725).
|
||||
if !req.claim() {
|
||||
continue;
|
||||
}
|
||||
handled += 1;
|
||||
let reply = req.reply.clone();
|
||||
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
|
||||
let roots = self.automation_roots.clone();
|
||||
let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) {
|
||||
self.ui.answering_control = true;
|
||||
let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)));
|
||||
self.ui.answering_control = false;
|
||||
let outcome = match answer {
|
||||
Ok(outcome) => outcome,
|
||||
Err(msg) => {
|
||||
self.picks.recover();
|
||||
@@ -954,6 +964,8 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the milliseconds since the Unix epoch, or in the web build the milliseconds since the
|
||||
/// first call, read from the page's `performance.now()`.
|
||||
pub fn now_ms() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -962,7 +974,10 @@ pub fn now_ms() -> f64 {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
use std::sync::LazyLock;
|
||||
use web_time::Instant;
|
||||
static START: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||
START.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,8 +992,10 @@ impl VectorcraftApp {
|
||||
self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts)
|
||||
}
|
||||
|
||||
/// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The
|
||||
/// canvas and the transform fields read it every frame: it is measured once per revision.
|
||||
/// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]); with
|
||||
/// anchors selected with Direct Selection, the box around them square to the page, which
|
||||
/// `object.setBounds` then edits (#1824). The canvas and the transform fields read it every
|
||||
/// frame: it is measured once per revision.
|
||||
pub fn selection_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
|
||||
let st = self.session.active()?;
|
||||
let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds);
|
||||
@@ -987,7 +1004,10 @@ impl VectorcraftApp {
|
||||
{
|
||||
return b;
|
||||
}
|
||||
let b = self.session.transform_box(&st.selection.objects);
|
||||
let b = match st.selection.anchor_bounds(&st.doc, self.session.prefs.use_preview_bounds) {
|
||||
Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)),
|
||||
None => self.session.transform_box(&st.selection.objects),
|
||||
};
|
||||
self.canvas.selection_box = Some((key, b));
|
||||
b
|
||||
}
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user