Compare commits
176
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0ea352df | ||
|
|
206e81c3b7 | ||
|
|
24afe0f517 | ||
|
|
ec67ed9646 | ||
|
|
ce558ffb73 | ||
|
|
cbe7f83881 | ||
|
|
feb319027e | ||
|
|
69e77570b4 | ||
|
|
4159ffe6fd | ||
|
|
51e7044b0e | ||
|
|
dae13d57f0 | ||
|
|
3374d0778d | ||
|
|
04bb6d6f27 | ||
|
|
ef0ab7ba9d | ||
|
|
546c81624a | ||
|
|
13bd24ba79 | ||
|
|
eb25a85d90 | ||
|
|
504e719230 | ||
|
|
d443cb031c | ||
|
|
41afc9f3e5 | ||
|
|
4d62e0d0ef | ||
|
|
1d1e28a51f | ||
|
|
f870f3ac09 | ||
|
|
67dcfd4fc4 | ||
|
|
972945f80d | ||
|
|
62036eac6c | ||
|
|
c49fb30032 | ||
|
|
0cc7e59df0 | ||
|
|
26ec0464bb | ||
|
|
f0810ca58b | ||
|
|
35d6aa0a16 | ||
|
|
36dc6e0c11 | ||
|
|
791bd91d5c | ||
|
|
17314d6b61 | ||
|
|
544b17ec33 | ||
|
|
5e8581e42f | ||
|
|
63686e659e | ||
|
|
7af5790b71 | ||
|
|
8a91be7983 | ||
|
|
5bfe774420 | ||
|
|
e918615347 | ||
|
|
250da4226e | ||
|
|
17b4396a97 | ||
|
|
5adeada26d | ||
|
|
175ad4a1e3 | ||
|
|
95304ad7f6 | ||
|
|
02da9bbdc9 | ||
|
|
331fc48b24 | ||
|
|
8d2d838884 | ||
|
|
bc5bdf47d1 | ||
|
|
6b99150aac | ||
|
|
26bb452ba1 | ||
|
|
dbb8bdcaaa | ||
|
|
ecf4ae7a3a | ||
|
|
e518974913 | ||
|
|
a20b0308b8 | ||
|
|
3126e82ebd | ||
|
|
d8a298a310 | ||
|
|
9c42a2942a | ||
|
|
36286cc783 | ||
|
|
bc343414d8 | ||
|
|
7b0164e4e7 | ||
|
|
670946ef75 | ||
|
|
bdb9963c73 | ||
|
|
dc64aaa41b | ||
|
|
5366cf689e | ||
|
|
cca3a63e4a | ||
|
|
507c6e0e63 | ||
|
|
dfc1b846cc | ||
|
|
a169693817 | ||
|
|
db85d465c8 | ||
|
|
de8d5c96ab | ||
|
|
d44615753a | ||
|
|
e794c1ac63 | ||
|
|
0573d3cdfb | ||
|
|
9ca74ac011 | ||
|
|
f0f3cb4eb1 | ||
|
|
5fe916a425 | ||
|
|
8e7b9ba436 | ||
|
|
cbcee3bd54 | ||
|
|
0810a15f6e | ||
|
|
db7e568809 | ||
|
|
9d72686d95 | ||
|
|
d79fb145c2 | ||
|
|
6a16627de6 | ||
|
|
acc1c9a85d | ||
|
|
8fbf724cf6 | ||
|
|
02ed637c68 | ||
|
|
9d2b074d12 | ||
|
|
632ea2a9f3 | ||
|
|
523fd6e185 | ||
|
|
612082e26e | ||
|
|
fea316911d | ||
|
|
3af08505f8 | ||
|
|
2c01a42b35 | ||
|
|
45334df881 | ||
|
|
ee97225c19 | ||
|
|
6085197bf4 | ||
|
|
50911d8608 | ||
|
|
6939eff2f9 | ||
|
|
9652d1d6b2 | ||
|
|
79b5d73ca7 | ||
|
|
05fdd15b45 | ||
|
|
0a16b62a22 | ||
|
|
20f5174710 | ||
|
|
00195e8d28 | ||
|
|
05e321f4e0 | ||
|
|
85770d3afb | ||
|
|
421443b8ac | ||
|
|
e2a63a8baf | ||
|
|
44d170f059 | ||
|
|
fce90e3144 | ||
|
|
761cc2371a | ||
|
|
3752b3ec31 | ||
|
|
c2ed31dc4c | ||
|
|
d9e44350d2 | ||
|
|
0f4de5b98e | ||
|
|
a971b5b70e | ||
|
|
6beaa89be2 | ||
|
|
40b02f479b | ||
|
|
afda1cd846 | ||
|
|
a7c6644691 | ||
|
|
3a88ad0855 | ||
|
|
61cc3f6040 | ||
|
|
3239e8b97e | ||
|
|
48d5bd1aee | ||
|
|
5203f4f047 | ||
|
|
9dfa7bf931 | ||
|
|
a072e29016 | ||
|
|
ba68fcfbfc | ||
|
|
02a68e1738 | ||
|
|
c9fac95760 | ||
|
|
b631002c58 | ||
|
|
041776fc9f | ||
|
|
78155ed5b8 | ||
|
|
51c18f603a | ||
|
|
66add30d65 | ||
|
|
b3f6edddfe | ||
|
|
a046fd30fe | ||
|
|
1e5f430ac5 | ||
|
|
f3040e3762 | ||
|
|
db54eefeae | ||
|
|
37954523ae | ||
|
|
c391e1dbf2 | ||
|
|
20ec49b2e7 | ||
|
|
8715130c8b | ||
|
|
6689be0351 | ||
|
|
c2c8a07b75 | ||
|
|
b83eafd794 | ||
|
|
e93fc20294 | ||
|
|
9e2a7c41a0 | ||
|
|
a4ba47a439 | ||
|
|
93e8c00607 | ||
|
|
1e0063258a | ||
|
|
15cef94ba6 | ||
|
|
431dc43e43 | ||
|
|
e750891912 | ||
|
|
fb2089aaf5 | ||
|
|
88bd4812e0 | ||
|
|
6f9e2d5c16 | ||
|
|
d084af162f | ||
|
|
e01b9a20d9 | ||
|
|
bd9b8a2db3 | ||
|
|
c67c25128c | ||
|
|
b9e5536794 | ||
|
|
15e1f6327f | ||
|
|
57a4d9c6e1 | ||
|
|
e87abf02a8 | ||
|
|
e4c2b3db94 | ||
|
|
2ec817e76a | ||
|
|
9e39a6d124 | ||
|
|
ec8dc0c2da | ||
|
|
22e06db73d | ||
|
|
aebeb66268 | ||
|
|
cf137c8c90 | ||
|
|
e131dfbb1c |
No files matched your search
Generated
+10
-1
@@ -4580,6 +4580,7 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"windows",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4653,6 +4654,7 @@ dependencies = [
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-scriptcheck",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
@@ -4688,6 +4690,7 @@ version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4821,6 +4824,13 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.10.0"
|
||||
@@ -4915,7 +4925,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -37,6 +37,7 @@ vectorcraft-eps = { path = "crates/eps" }
|
||||
vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -65,6 +66,7 @@ skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
linesweeper = "0.5"
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
rustix = { version = "1.1", default-features = false }
|
||||
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -148,6 +148,9 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/vectorcraft
|
||||
cd vectorcraft
|
||||
|
||||
cargo run --release -p vectorcraft # desktop app
|
||||
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft
|
||||
cargo run --release -p vectorcraft -- --control 7979 # + JSON control channel
|
||||
@@ -217,7 +220,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against Illustrator's public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
+9
-5
@@ -6,7 +6,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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-13 · **Change:** minor (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
|
||||
@@ -35,7 +35,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|
||||
|---|---:|---:|---:|---|
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
|
||||
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
|
||||
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
|
||||
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
|
||||
|
||||
@@ -52,7 +52,7 @@ everything is measured from our code against Illustrator's public documentation.
|
||||
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
|
||||
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization (12 key languages) | 63% (measured) | 60–100 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
|
||||
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
|
||||
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
|
||||
@@ -107,7 +107,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Japanese | `ja` | full | 100% |
|
||||
| German | `de` | full | 98% |
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | none | 0% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
@@ -127,7 +127,11 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | Image Trace: neighbouring colours share one fitted edge (gaps between them 22 to 50 px of area down to under 2), a 16 megapixel photo traces in 0.9 GB (was 3.9), stroked centre lines lie in the middle of their ink at its width (0.30 to 0.05 px off, 18 to 3 anchors a stroke), nearly level lines are levelled, more sharp corners are kept; new: Ignore Color, the Auto Color and Outline presets, and the Shapes, Auto Grouping and Gradients options (off by default) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
|
||||
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
|
||||
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
|
||||
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
|
||||
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
|
||||
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
|
||||
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
|
||||
@@ -135,7 +139,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
|
||||
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
|
||||
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
|
||||
@@ -26,6 +26,12 @@ vectorcraft-text = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# `perf` on Windows (src/perf.rs) reads how busy the processors are (GetSystemTimes) and this
|
||||
# process's working set (GetProcessMemoryInfo) through winsafe's safe wrappers over Win32 (MIT),
|
||||
# which the desktop app's src/gpu.rs also uses.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
|
||||
|
||||
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
|
||||
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
//! vectorcraft-cli perf [--paths 50000] [--deep]
|
||||
//! ```
|
||||
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
|
||||
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
|
||||
@@ -93,7 +93,8 @@ USAGE:
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
|
||||
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
|
||||
draws differently from its PDF part (which can be older), as long as it can be read.
|
||||
draws differently from its PDF part (which can be older), as long as it can be read. The
|
||||
result has the export's warnings, and importWarnings for what reading IN left out.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
|
||||
@@ -104,9 +105,11 @@ USAGE:
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
vectorcraft-cli perf [--paths N] [--deep]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
|
||||
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
|
||||
budget is exceeded, unless the report says the machine is busy. --deep also times work with
|
||||
no budget yet and prints memory use (the working set on Windows).
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
@@ -235,19 +238,24 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
||||
let mut h = Headless::new();
|
||||
if editing_data {
|
||||
let opened = if editing_data {
|
||||
let params = json!({"path": input, "editingData": "always"});
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?
|
||||
} else {
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
|
||||
}
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
|
||||
};
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
|
||||
// much as what writing the output did.
|
||||
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
|
||||
out.insert("importWarnings".into(), w.clone());
|
||||
}
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! `vectorcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
|
||||
//!
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
|
||||
//! average above the core count) they are noise, and the report says so.
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
|
||||
//! average above three quarters of the cores, or on Windows more than three quarters of the
|
||||
//! cores busy over a second) they are noise, and the report says so.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -61,6 +62,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn load_average() -> Option<f64> {
|
||||
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
|
||||
let text = match out {
|
||||
@@ -70,6 +72,27 @@ fn load_average() -> Option<f64> {
|
||||
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
|
||||
}
|
||||
|
||||
/// The cores busy on average over one second (Windows), if available: the share of the
|
||||
/// processors' time that `GetSystemTimes` reports as kernel or user time but not as idle time,
|
||||
/// times `cores`. A load average also counts the threads waiting for a core.
|
||||
#[cfg(windows)]
|
||||
fn busy_cores(cores: usize) -> Option<f64> {
|
||||
let times = || winsafe::GetSystemTimes().ok().map(|(idle, kernel, user)| [idle, kernel, user].map(u64::from));
|
||||
let before = times()?;
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
busy_share(before, times()?).map(|share| share * cores as f64)
|
||||
}
|
||||
|
||||
/// The share of the processors' time between two `[idle, kernel, user]` readings of
|
||||
/// `GetSystemTimes` that was busy (kernel time includes idle time). It is None when no time
|
||||
/// passed, a time went back, or the idle time exceeds the kernel and user time.
|
||||
#[cfg(any(windows, test))]
|
||||
fn busy_share([idle0, kernel0, user0]: [u64; 3], [idle1, kernel1, user1]: [u64; 3]) -> Option<f64> {
|
||||
let total = kernel1.checked_sub(kernel0)?.checked_add(user1.checked_sub(user0)?)?;
|
||||
let busy = total.checked_sub(idle1.checked_sub(idle0)?)?;
|
||||
(total > 0).then(|| busy as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
|
||||
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
|
||||
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
@@ -114,12 +137,21 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
}
|
||||
|
||||
/// Resident memory of this process in MB (`ps`), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let pid = std::process::id().to_string();
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
|
||||
}
|
||||
|
||||
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
|
||||
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
|
||||
#[cfg(windows)]
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
|
||||
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
|
||||
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
|
||||
fn deep(n: usize) -> Result<(), String> {
|
||||
@@ -243,8 +275,10 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
for (name, ms) in &rows {
|
||||
outln!(" {name:<52} {ms:>10.2} ms");
|
||||
}
|
||||
// `rss_mb` reads the working set on Windows.
|
||||
let memory = if cfg!(windows) { "working set" } else { "memory" };
|
||||
for (what, mb) in &mem {
|
||||
outln!(" memory {what:<45} {mb:>8.0} MB");
|
||||
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -261,8 +295,12 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
|
||||
#[cfg(not(windows))]
|
||||
let (load, label) = (load_average(), "load average");
|
||||
#[cfg(windows)]
|
||||
let (load, label) = (busy_cores(cores), "cores busy");
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, {label} {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
@@ -358,3 +396,38 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn busy_share_leaves_out_idle_time() {
|
||||
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
|
||||
// 10,000,000 of them idle.
|
||||
assert_eq!(busy_share([7, 100, 50], [10_000_007, 25_000_100, 15_000_050]), Some(0.75));
|
||||
// There is no share when no time passed, a time went back, or the idle time exceeds the
|
||||
// kernel and user time.
|
||||
assert_eq!(busy_share([7, 100, 50], [7, 100, 50]), None);
|
||||
assert_eq!(busy_share([7, 100, 50], [8, 99, 60]), None);
|
||||
assert_eq!(busy_share([0, 0, 0], [30, 10, 10]), None);
|
||||
}
|
||||
|
||||
/// On Windows `busy_cores` gives a number between 0 and the cores.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_how_many_cores_are_busy() {
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let busy = busy_cores(cores);
|
||||
assert!(busy.is_some_and(|b| (0.0..=cores as f64).contains(&b)), "{busy:?} of {cores}");
|
||||
}
|
||||
|
||||
/// On Windows `rss_mb` gives this process's working set: more than 0 MB and less than
|
||||
/// 10,000 MB.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reads_the_working_set() {
|
||||
let mb = rss_mb();
|
||||
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,12 @@ fn help_lists_readable_formats() {
|
||||
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_perf_deep() {
|
||||
let out = ok(&["--help"]);
|
||||
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
|
||||
}
|
||||
|
||||
/// `info` reports the import warnings: an EPS read only up to an error names it.
|
||||
#[test]
|
||||
fn info_reports_why_an_eps_read_partly() {
|
||||
@@ -93,3 +99,19 @@ fn info_reports_why_an_eps_read_partly() {
|
||||
assert!(w.contains("`frobnicate`"), "{v}");
|
||||
assert_eq!(v["kinds"]["Path"], 1, "{v}");
|
||||
}
|
||||
|
||||
/// #1604: convert reports what reading the input left out (here a missing linked image) as
|
||||
/// importWarnings, next to the export's own warnings.
|
||||
#[test]
|
||||
fn convert_reports_import_warnings() {
|
||||
let svg = tmp("missing-link.svg");
|
||||
std::fs::write(
|
||||
&svg,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image width="40" height="40" href="nowhere-to-be-found.png"/></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let png = tmp("missing-link.png");
|
||||
let out: Value = serde_json::from_str(ok(&["convert", &svg, &png]).trim()).unwrap();
|
||||
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
|
||||
}
|
||||
@@ -2,20 +2,21 @@
|
||||
//! (`Services::system_clipboard`).
|
||||
//!
|
||||
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
|
||||
//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! Paste reads each of them and EPS, a bitmap as PNG, else as the device-independent bitmap that
|
||||
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
|
||||
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF
|
||||
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
|
||||
//! else the PNG as a bitmap; Paste reads text and bitmaps, and on macOS the pasteboard's SVG, PDF
|
||||
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
|
||||
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
|
||||
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol.
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
|
||||
//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour};
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
use vectorcraft_ui_egui::SystemClipboard;
|
||||
|
||||
/// This platform's system clipboard.
|
||||
@@ -27,6 +28,34 @@ pub fn system_clipboard() -> Box<dyn SystemClipboard> {
|
||||
cb
|
||||
}
|
||||
|
||||
/// The registered Windows clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn windows_formats(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
EPS => &["Encapsulated PostScript", "application/postscript"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard types Paste reads for `mime` (`image/*`: any bitmap), best first, each with
|
||||
/// the MIME type of its data. They are the system's uniform type identifiers (the values of
|
||||
/// `NSPasteboardTypePDF`, `NSPasteboardTypePNG` and `NSPasteboardTypeTIFF` among them) and the
|
||||
/// older name some apps still give PDF. Text is read through arboard.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn pasteboard_types(mime: &str) -> &'static [(&'static str, &'static str)] {
|
||||
match mime {
|
||||
SVG => &[("public.svg-image", SVG)],
|
||||
PDF => &[("com.adobe.pdf", PDF), ("Apple PDF pasteboard type", PDF)], // brand-ok: the system's type identifier
|
||||
EPS => &[("com.adobe.encapsulated-postscript", EPS)], // brand-ok: the system's type identifier
|
||||
BITMAP => &[("public.png", PNG), ("public.tiff", "image/tiff")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The text flavour among `flavours`.
|
||||
fn text_of(flavours: &[Flavour]) -> Option<String> {
|
||||
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
|
||||
@@ -127,27 +156,15 @@ mod win {
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The registered clipboard formats a flavour is written under (and read from, first held
|
||||
/// first).
|
||||
fn names(mime: &str) -> &'static [&'static str] {
|
||||
match mime {
|
||||
SVG => &["image/svg+xml"],
|
||||
PDF => &["Portable Document Format", "application/pdf"],
|
||||
PNG => &["PNG"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
|
||||
/// other or a `CF_BITMAP` is: the system converts between them.
|
||||
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
|
||||
|
||||
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
|
||||
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
windows_formats(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
@@ -350,27 +367,30 @@ mod portable {
|
||||
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
|
||||
return Some(f);
|
||||
}
|
||||
mimes.iter().find_map(|m| match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::bitmap() {
|
||||
return Some(f);
|
||||
mimes.iter().find_map(|m| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::read(m) {
|
||||
return Some(f);
|
||||
}
|
||||
match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
BITMAP => {
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba =
|
||||
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
_ => None,
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
|
||||
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
|
||||
/// menu item waits for text.
|
||||
/// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
|
||||
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
|
||||
/// take it, the menu item waits for text.
|
||||
fn has(&mut self, mimes: &[&'static str]) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
|
||||
if super::mac::has(mimes) {
|
||||
return true;
|
||||
}
|
||||
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
|
||||
@@ -378,7 +398,8 @@ mod portable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
|
||||
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
|
||||
/// pictures TIFF only, decoded).
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac {
|
||||
use objc2::rc::autoreleasepool;
|
||||
@@ -387,24 +408,20 @@ mod mac {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
|
||||
/// `NSPasteboardTypeTIFF`), with their MIME types.
|
||||
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
|
||||
|
||||
/// Does the pasteboard hold a bitmap? Asks for its types only.
|
||||
pub fn has_bitmap() -> bool {
|
||||
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
|
||||
pub fn has(mimes: &[&'static str]) -> bool {
|
||||
autoreleasepool(|_| {
|
||||
let types = TYPES.map(|(t, _)| NSString::from_str(t));
|
||||
let types: Vec<_> = mimes.iter().flat_map(|m| pasteboard_types(m)).map(|(t, _)| NSString::from_str(t)).collect();
|
||||
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
|
||||
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
!types.is_empty() && NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
/// The pasteboard's bitmap, PNG before TIFF.
|
||||
pub fn bitmap() -> Option<Flavour> {
|
||||
/// The pasteboard's data for `mime`, the first of its types held ([`pasteboard_types`]).
|
||||
pub fn read(mime: &str) -> Option<Flavour> {
|
||||
autoreleasepool(|_| {
|
||||
let pasteboard = NSPasteboard::generalPasteboard();
|
||||
TYPES.into_iter().find_map(|(t, mime)| {
|
||||
pasteboard_types(mime).iter().find_map(|&(t, mime)| {
|
||||
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
|
||||
(!data.is_empty()).then_some(Flavour { mime, data })
|
||||
})
|
||||
@@ -414,7 +431,7 @@ mod mac {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, PASTE_ORDER};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -448,6 +465,36 @@ mod tests {
|
||||
assert!(copied_file(vec![], &PASTE_ORDER).is_none());
|
||||
}
|
||||
|
||||
/// What another app copied as vector art is asked for under the names the platforms give it,
|
||||
/// and read as the flavour the paste imports (#1499).
|
||||
#[test]
|
||||
fn vector_art_is_read_under_each_platforms_names() {
|
||||
let mac = |m| pasteboard_types(m).iter().map(|t| t.0).collect::<Vec<_>>();
|
||||
assert_eq!(mac(PDF), ["com.adobe.pdf", "Apple PDF pasteboard type"]);
|
||||
assert_eq!(mac(EPS), ["com.adobe.encapsulated-postscript"]);
|
||||
assert_eq!(mac(SVG), ["public.svg-image"]);
|
||||
assert_eq!(pasteboard_types(BITMAP), [("public.png", PNG), ("public.tiff", "image/tiff")]);
|
||||
assert!(windows_formats(EPS).contains(&"Encapsulated PostScript") && windows_formats(PDF).contains(&"Portable Document Format"));
|
||||
for asked in PASTE_ORDER {
|
||||
// Each type's data is the flavour asked for (a bitmap its own type): what
|
||||
// `clipboard.import*` then reads. Text and metafiles come another way.
|
||||
assert!(pasteboard_types(asked).iter().all(|(_, mime)| *mime == asked || asked == BITMAP), "{asked}");
|
||||
assert_eq!(pasteboard_types(asked).is_empty(), [TEXT, EMF].contains(&asked), "{asked}");
|
||||
}
|
||||
// Vector art before text, text before bitmaps.
|
||||
let at = |m| PASTE_ORDER.iter().position(|x| *x == m).unwrap();
|
||||
assert!(at(PDF) < at(EPS) && at(EPS) < at(TEXT) && at(TEXT) < at(BITMAP));
|
||||
}
|
||||
|
||||
/// An .eps file copied in a file manager pastes as EPS.
|
||||
#[test]
|
||||
fn a_copied_eps_file_pastes() {
|
||||
let eps = dir("eps").join("equation.eps");
|
||||
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
|
||||
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
|
||||
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crlf_left_by_the_uri_list_is_ignored() {
|
||||
let red = dir("crlf").join("red.png");
|
||||
|
||||
@@ -89,3 +89,12 @@ sync_github_name = false
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lewandowskit]
|
||||
real_name = "Tomasz Lewandowski"
|
||||
display_name = "Tomasz Lewandowski"
|
||||
sync_github_name = false
|
||||
@@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
|
||||
|
||||
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
|
||||
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?);
|
||||
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
|
||||
if let Some(date) = bytes.get_mut(24..36) {
|
||||
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
|
||||
@@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1
|
||||
/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584).
|
||||
/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is.
|
||||
fn aligned(icc: Vec<u8>) -> Vec<u8> {
|
||||
const TABLE: usize = 128;
|
||||
let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
|
||||
let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc };
|
||||
let data_start = TABLE + 4 + 12 * n;
|
||||
let mut entries = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let e = TABLE + 4 + 12 * i;
|
||||
let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc };
|
||||
if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) {
|
||||
return icc;
|
||||
}
|
||||
entries.push((off, size));
|
||||
}
|
||||
if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) {
|
||||
return icc;
|
||||
}
|
||||
let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
let mut moved: Vec<((usize, usize), usize)> = Vec::new();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by_key(|&i| entries[i].0);
|
||||
for i in order {
|
||||
let (off, size) = entries[i];
|
||||
let at = match moved.iter().find(|(k, _)| *k == (off, size)) {
|
||||
Some((_, at)) => *at,
|
||||
None => {
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let at = out.len();
|
||||
out.extend_from_slice(&icc[off..off + size]);
|
||||
moved.push(((off, size), at));
|
||||
at
|
||||
}
|
||||
};
|
||||
let e = TABLE + 4 + 12 * i + 4;
|
||||
out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes());
|
||||
}
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
let len = out.len() as u32;
|
||||
out[0..4].copy_from_slice(&len.to_be_bytes());
|
||||
// The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed".
|
||||
if let Some(id) = out.get_mut(84..100) {
|
||||
id.fill(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text_tag(s: &str) -> Option<ProfileText> {
|
||||
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
|
||||
}
|
||||
|
||||
@@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
|
||||
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
|
||||
assert_eq!(abs(true), abs(false));
|
||||
}
|
||||
|
||||
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
|
||||
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
|
||||
#[test]
|
||||
fn written_profiles_align_their_tags() {
|
||||
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
|
||||
let icc = super::icc_bytes(name).unwrap();
|
||||
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
|
||||
assert_eq!(be(0), icc.len(), "{name}: header size");
|
||||
assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len());
|
||||
let n = be(128);
|
||||
for i in 0..n {
|
||||
let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8));
|
||||
assert_eq!(off % 4, 0, "{name}: tag {i} at {off}");
|
||||
assert!(off + size <= icc.len());
|
||||
}
|
||||
let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap();
|
||||
let orig = moxcms::ColorProfile::new_srgb();
|
||||
if name == super::SRGB {
|
||||
assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant));
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
-16
@@ -1,5 +1,7 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
@@ -83,6 +85,77 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
|
||||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
|
||||
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
|
||||
// while a group (including a clipping group) is selected as one object. Repeatedly
|
||||
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
|
||||
// Isolation and Edit Contents change where selection groups begin; keep the original
|
||||
// traversal for those cases rather than change their Select Behind semantics.
|
||||
if opt.scope.is_some() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
let mut tops = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for layer in doc.layers.iter().rev() {
|
||||
// Malformed files may contain non-layer roots; let the general traversal handle them.
|
||||
if !layer.is_layer() {
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
if !objects_at_layer(layer, p, opt, &mut tops, &mut seen) {
|
||||
// An envelope in Edit Contents may expose objects *within* a top-level group.
|
||||
// The repeated traversal handles that mixed hierarchy precisely.
|
||||
return objects_at_repeated(doc, p, opt, scope);
|
||||
}
|
||||
}
|
||||
tops
|
||||
}
|
||||
|
||||
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
|
||||
/// inspected once (only until its first hit). `false` asks the caller to use the general
|
||||
/// algorithm for an envelope whose contents are currently being edited.
|
||||
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
|
||||
let Some(children) = layer.children() else { return true };
|
||||
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return true;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if !may_hit(layer, i, c, p, opt, true) || seen.contains(&c.id) {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
if !objects_at_layer(c, p, opt, tops, seen) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Editing an envelope selects its contents rather than the containing object. Even
|
||||
// when nested in a group, the old traversal may select both a member and the group.
|
||||
if c.is_container() {
|
||||
let mut editing = false;
|
||||
c.walk(&mut |n| editing |= edits_contents(n));
|
||||
if editing {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let skip = |id| seen.contains(&id);
|
||||
let mut chain = Vec::new();
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Group { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some(),
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt).is_some(),
|
||||
_ => hit_leaf(c, p, opt).is_some(),
|
||||
};
|
||||
if hit && seen.insert(c.id) {
|
||||
tops.push(c.id);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The general selection order also handles isolation and nested Edit Contents.
|
||||
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
let mut tops: Vec<NodeId> = vec![];
|
||||
// Each round leaves out the objects found so far (and everything inside them).
|
||||
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
|
||||
@@ -150,6 +223,22 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
|
||||
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
|
||||
/// isn't shown, or when `p` is outside its reach.
|
||||
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, editable_only: bool) -> bool {
|
||||
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
|
||||
return false;
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) {
|
||||
return false;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c) || c.extras().shaper.is_some() && source_isolated(c) || c.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
@@ -167,22 +256,7 @@ fn hit_children(
|
||||
return None;
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
if parent.extras().shaper.is_some() {
|
||||
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
|
||||
if (i == 0) != construction {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !(edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
if !may_hit(parent, i, c, p, opt, editable_only) || skip(c.id) {
|
||||
continue;
|
||||
}
|
||||
chain.push(c.id);
|
||||
@@ -619,6 +693,146 @@ world",
|
||||
assert!(at(90.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
|
||||
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
|
||||
/// the per-object walk.
|
||||
#[test]
|
||||
fn objects_at_large_overlapping_stack() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..256 {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
ids.reverse();
|
||||
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
|
||||
}
|
||||
|
||||
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
|
||||
/// invisibility, and path/outline-only settings.
|
||||
#[test]
|
||||
fn objects_at_one_pass_matches_repeated_hits() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let layer = d.layers[0].id;
|
||||
let add = |d: &mut Document, parent, bounds| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
|
||||
id
|
||||
};
|
||||
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
|
||||
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
let group = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
|
||||
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
|
||||
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
|
||||
let clip = d.alloc_id();
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
|
||||
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
|
||||
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
|
||||
clipping.set_clips(true);
|
||||
d.insert(Some(layer), usize::MAX, clipping).unwrap();
|
||||
for (point, opt) in [
|
||||
(Point::new(25.0, 25.0), HitOptions::default()),
|
||||
(Point::new(3.0, 25.0), HitOptions::default()),
|
||||
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
|
||||
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
|
||||
(Point::new(100.0, 100.0), HitOptions::default()),
|
||||
] {
|
||||
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
|
||||
}
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
d.node_mut(group).unwrap().locked = true;
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
}
|
||||
|
||||
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
|
||||
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
|
||||
/// in the same order as the hit test repeated over what is left.
|
||||
#[test]
|
||||
fn objects_at_matches_repeated_hits_on_random_documents() {
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
let mut next = move |n: u32| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed % n
|
||||
};
|
||||
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
|
||||
for _ in 0..2 + next(4) {
|
||||
let id = d.alloc_id();
|
||||
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
|
||||
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
|
||||
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
|
||||
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
|
||||
4 | 5 => Node::group(id, Vec::new()),
|
||||
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
|
||||
7 => {
|
||||
let mut g = Node::group(id, Vec::new());
|
||||
g.set_clips(true);
|
||||
g
|
||||
}
|
||||
_ => Node::path(id, shapes::rectangle(rect), appearance),
|
||||
};
|
||||
n.visible = next(8) != 0;
|
||||
n.locked = next(8) == 0;
|
||||
if let NodeKind::Layer { preview, .. } = &mut n.kind {
|
||||
*preview = next(3) != 0;
|
||||
}
|
||||
let container = n.is_container();
|
||||
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
if container {
|
||||
fill(d, id, depth + 1, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
for round in 0..200 {
|
||||
let mut d = Document::new(120.0, 120.0);
|
||||
let layer = d.layers[0].id;
|
||||
fill(&mut d, layer, 0, &mut next);
|
||||
for _ in 0..12 {
|
||||
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
|
||||
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit Contents can expose descendants of a top-level group: preserve the original
|
||||
/// selection order by falling back to the general hit traversal.
|
||||
#[test]
|
||||
fn objects_at_edit_contents_uses_general_order() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
|
||||
let (a, b) = (d.alloc_id(), d.alloc_id());
|
||||
let e = Node::new(
|
||||
d.alloc_id(),
|
||||
NodeKind::Envelope {
|
||||
content: vec![inner(a), inner(b)],
|
||||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||||
fidelity: 100.0,
|
||||
editing: true,
|
||||
options: Default::default(),
|
||||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
d.insert(Some(layer), usize::MAX, e).unwrap();
|
||||
let p = Point::new(25.0, 25.0);
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
|
||||
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_skip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -1465,6 +1465,14 @@ impl Document {
|
||||
pub fn new_graphic_style_name(&self) -> String {
|
||||
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
|
||||
}
|
||||
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
|
||||
pub fn unique_symbol_name(&self, base: &str) -> String {
|
||||
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
|
||||
+39
-8
@@ -318,8 +318,10 @@ impl WarpStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
|
||||
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
|
||||
/// proportions).
|
||||
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
|
||||
use WarpStyle::*;
|
||||
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
|
||||
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
|
||||
@@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) ->
|
||||
if b.abs() < 1e-6 {
|
||||
(x, y)
|
||||
} else {
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
|
||||
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
|
||||
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
|
||||
// the edges are concentric arcs, and one that would pass the centre stops there
|
||||
// instead of folding over (#1578).
|
||||
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
|
||||
let sweep = b.abs() * PI;
|
||||
let r0 = 2.0 / sweep;
|
||||
let yy = if b > 0.0 { y } else { -y };
|
||||
let yy = if b > 0.0 { y } else { -y } * k;
|
||||
let a = x / r0;
|
||||
let r = r0 - yy;
|
||||
let r = (r0 - yy).max(0.0);
|
||||
let (s, c) = a.sin_cos();
|
||||
let (nx, ny) = (r * s, r0 - r * c);
|
||||
let (nx, ny) = (r * s, (r0 - r * c) / k);
|
||||
(nx, if b > 0.0 { ny } else { -ny })
|
||||
}
|
||||
}
|
||||
@@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
|
||||
Box::new(move |u: f64, v: f64| {
|
||||
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
|
||||
let (x2, y2) = if horizontal {
|
||||
warp_point(st, b, dh, dv, x, y)
|
||||
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
|
||||
} else {
|
||||
let (a, b2) = warp_point(st, b, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
|
||||
(b2, a)
|
||||
};
|
||||
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
@@ -1711,6 +1717,31 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
|
||||
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
|
||||
/// real distance from the centre line; an inner edge that would pass the centre stops there.
|
||||
#[test]
|
||||
fn arc_warp_is_round_in_real_proportions() {
|
||||
for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] {
|
||||
let r0 = 2.0 / (b * PI);
|
||||
for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] {
|
||||
for i in 0..=10 {
|
||||
let x = -1.0 + 0.2 * f64::from(i);
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y);
|
||||
let d = nx.hypot(ny * aspect - r0);
|
||||
assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// At 100 % on a square the inner edge meets at the centre instead of folding over.
|
||||
let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0);
|
||||
assert!(nx.abs() < 1e-12);
|
||||
// A square bent less than that is what it always was.
|
||||
let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25);
|
||||
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
|
||||
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
|
||||
}
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Distort & Transform effects.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::util::*;
|
||||
@@ -194,7 +195,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
|
||||
map_nonlinear(path, pieces, f)
|
||||
}
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
|
||||
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
|
||||
|
||||
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
|
||||
/// displaced along the normal.
|
||||
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
|
||||
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
|
||||
@@ -207,8 +211,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
|
||||
let mut k = 0usize;
|
||||
for i in 0..segs {
|
||||
let c = sp.segment(i);
|
||||
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
|
||||
// near the anchors, and so does any uneven curve.
|
||||
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
|
||||
for j in 0..=ridges {
|
||||
let t = j as f64 / (ridges + 1) as f64;
|
||||
let s = j as f64 / (ridges + 1) as f64;
|
||||
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
|
||||
let nrm = if j == 0 {
|
||||
// Average the normals of the segments meeting at the anchor.
|
||||
let prev = if i > 0 {
|
||||
|
||||
@@ -207,15 +207,15 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 50), twist: degrees (-360..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
@@ -249,7 +249,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.freeDistort",
|
||||
"Free Distort…",
|
||||
DT,
|
||||
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
|
||||
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}",
|
||||
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
|
||||
),
|
||||
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
|
||||
|
||||
@@ -90,7 +90,14 @@ pub fn extrude_options(p: &Value) -> Extrude {
|
||||
}
|
||||
|
||||
pub fn inflate_options(p: &Value) -> Inflate {
|
||||
Inflate { depth: num(p, "depth", 0.0), volume: num(p, "volume", 50.0), both_sides: flag(p, "bothSides", false) }
|
||||
Inflate {
|
||||
depth: num(p, "depth", 0.0),
|
||||
volume: num(p, "volume", 50.0),
|
||||
both_sides: flag(p, "bothSides", false),
|
||||
twist: num(p, "twist", 0.0),
|
||||
taper: num(p, "taper", 100.0) / 100.0,
|
||||
cap: flag(p, "cap", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape a solid is made from: a path's or a compound path's outline.
|
||||
|
||||
@@ -116,10 +116,36 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
||||
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
(None, None) => plain_outline(line, &style(st, width), tol),
|
||||
(None, None) => {
|
||||
let mut out = plain_outline(line, &style(st, width), tol);
|
||||
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
|
||||
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
|
||||
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
|
||||
/// (SVG 2, "Path data": zero-length path segments).
|
||||
fn point_dots(line: &BezPath) -> Vec<Dot> {
|
||||
let els = line.elements();
|
||||
subpaths(line)
|
||||
.into_iter()
|
||||
.filter_map(|(r, _)| {
|
||||
let sub = els.get(r)?;
|
||||
let at = sub.first()?.end_point()?;
|
||||
let still = sub.iter().all(|e| match e {
|
||||
PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at,
|
||||
PathEl::QuadTo(a, b) => *a == at && *b == at,
|
||||
PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at,
|
||||
PathEl::ClosePath => true,
|
||||
});
|
||||
still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `line` stroked with `style`, to fill with the non-zero rule.
|
||||
///
|
||||
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
|
||||
|
||||
@@ -244,6 +244,62 @@ fn zigzag_ridge_count_and_amplitude() {
|
||||
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
|
||||
// #1554: on a straight line every ridge is the same distance from the line and as far from
|
||||
// the next one, whether the points are corners or smooth.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false));
|
||||
for points in ["corner", "smooth"] {
|
||||
let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line);
|
||||
let ps: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(ps.len(), 6, "{points}");
|
||||
for (k, q) in ps.iter().enumerate() {
|
||||
assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}");
|
||||
assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
|
||||
/// turn all the way round.
|
||||
#[test]
|
||||
fn zigzag_on_a_circle_is_regular() {
|
||||
let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0));
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle);
|
||||
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts.len(), 16);
|
||||
let start = pts[0].y.atan2(pts[0].x);
|
||||
let mut radii = vec![];
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5;
|
||||
assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths");
|
||||
radii.push(q.to_vec2().hypot().round());
|
||||
}
|
||||
assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}");
|
||||
}
|
||||
|
||||
/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts.
|
||||
#[test]
|
||||
fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() {
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let curve = PathData::single(SubPath::new(
|
||||
vec![
|
||||
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
|
||||
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
|
||||
],
|
||||
false,
|
||||
));
|
||||
let c = curve.subpaths[0].segment(0);
|
||||
let len = c.arclen(1e-6);
|
||||
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve);
|
||||
let along: Vec<f64> = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect();
|
||||
assert_eq!(along.len(), 5);
|
||||
for (k, share) in along.iter().enumerate() {
|
||||
assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_grows_and_shrinks() {
|
||||
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
|
||||
|
||||
@@ -120,6 +120,32 @@ fn export_and_expand_bake_faces_with_their_own_ids() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A twisted, tapered, hollow Inflate (both sides) exports and expands like the others: faces with
|
||||
/// their own ids, no 3D effect left on them.
|
||||
#[test]
|
||||
fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut n = square_node();
|
||||
n.id = d.alloc_id();
|
||||
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
|
||||
n.appearance.effects.push(fx(INFLATE, params));
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
|
||||
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||||
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id), "{:?} twice", n.id);
|
||||
assert!(!has_solid(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&expanded, &mut ids);
|
||||
assert!(ids.len() > 20, "{} nodes", ids.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
@@ -156,3 +182,12 @@ fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||||
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
|
||||
assert!(extrude_options(&json!({})).bevel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_reads_twist_taper_and_cap() {
|
||||
let o = inflate_options(&json!({"twist": 90, "taper": 40, "cap": false}));
|
||||
assert_eq!((o.twist, o.taper, o.cap), (90.0, 0.4, false));
|
||||
let d = inflate_options(&json!({}));
|
||||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
@@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
|
||||
let f = |q: Point| {
|
||||
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
|
||||
let (x2, y2) = if vertical {
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
|
||||
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
|
||||
(b2, a)
|
||||
} else {
|
||||
warp_point(style, bend, dh, dv, x, y)
|
||||
warp_point(style, bend, dh, dv, hh / hw, x, y)
|
||||
};
|
||||
Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ vectorcraft-eps = { workspace = true }
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-affinity = { workspace = true }
|
||||
vectorcraft-scriptcheck = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
||||
@@ -15,6 +15,7 @@ use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKin
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -525,14 +526,6 @@ fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
Ok((art, (w, h), c))
|
||||
}
|
||||
|
||||
fn unique_symbol_name(d: &Document, base: &str) -> String {
|
||||
let taken = |n: &str| d.symbols.iter().any(|s| s.name == n);
|
||||
if !taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
@@ -569,6 +562,10 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id.
|
||||
fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
|
||||
let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
if separate(d, ids) {
|
||||
let id = d.alloc_id();
|
||||
return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?);
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?;
|
||||
@@ -582,7 +579,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
@@ -742,9 +739,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if expand {
|
||||
break_links(d, &inst)?;
|
||||
} else {
|
||||
for i in inst {
|
||||
d.remove(i)?;
|
||||
}
|
||||
remove_each(d, &inst)?;
|
||||
}
|
||||
d.symbols.retain(|x| x.name != name);
|
||||
set_natural_size(d, &name, None);
|
||||
@@ -761,7 +756,7 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, C)?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
|
||||
let new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let size = natural_size(d, &name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
@@ -871,12 +866,7 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
let n = doomed.len();
|
||||
if n > 0 {
|
||||
s.edit("Symbol Sprayer", |d, _| {
|
||||
for id in &doomed {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?;
|
||||
}
|
||||
return Ok(json!({ "count": n }));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -26,7 +26,7 @@ use vectorcraft_trace as tr;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::paint::paint_from;
|
||||
use super::pathops::shape_node;
|
||||
use super::pathops::{Beside, shape_node};
|
||||
use super::*;
|
||||
|
||||
/// Name of an Image Trace group.
|
||||
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -341,19 +341,16 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
|
||||
/// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.)
|
||||
fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result<Vec<NodeId>> {
|
||||
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let before = roots.iter().filter(|r| **r != top && d.position(**r).is_some_and(|(p, i, _)| p == par && i < idx)).count();
|
||||
for r in roots {
|
||||
let Some((&top, rest)) = roots.split_last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let ids = nodes.iter().map(|n| n.id).collect();
|
||||
let mut replaced = Beside::replacing(d, roots);
|
||||
for r in rest {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
replaced.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let at = idx - before;
|
||||
let mut ids = vec![];
|
||||
for (k, n) in nodes.into_iter().enumerate() {
|
||||
ids.push(d.insert(par, at + k, n)?);
|
||||
}
|
||||
replaced.put(d, top, nodes)?;
|
||||
replaced.finish(d)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -508,14 +505,14 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Arc::make_mut(e).appearance = app.into();
|
||||
}
|
||||
}
|
||||
let mut merged = Beside::replacing(d, &roots);
|
||||
for r in roots.iter().filter(|r| **r != target) {
|
||||
if d.node(*r).is_some() {
|
||||
d.remove(*r)?;
|
||||
merged.put(d, *r, vec![])?;
|
||||
}
|
||||
}
|
||||
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
|
||||
d.remove(target)?;
|
||||
d.insert(par, idx, g)?;
|
||||
merged.put(d, target, vec![g])?;
|
||||
merged.finish(d)?;
|
||||
sel.set([target]);
|
||||
Ok((target, nf, ne))
|
||||
})?;
|
||||
@@ -536,11 +533,14 @@ fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = live_targets(s, p)?;
|
||||
let ids = s.edit("Release Live Paint", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let srcs: Vec<Node> = source_nodes(&n).iter().map(|c| (**c).clone()).collect();
|
||||
out.extend(replace_roots(d, &[*g], srcs)?);
|
||||
out.extend(srcs.iter().map(|c| c.id));
|
||||
released.put(d, *g, srcs)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -703,6 +703,76 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
|
||||
/// refused with advice instead (#525).
|
||||
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
|
||||
|
||||
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
|
||||
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
|
||||
use vectorcraft_geom::Affine;
|
||||
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
|
||||
let mut live = match shape {
|
||||
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
|
||||
w: width,
|
||||
h: height,
|
||||
radii: [radius; 4],
|
||||
kinds: Default::default(),
|
||||
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
|
||||
},
|
||||
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
|
||||
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
|
||||
}
|
||||
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
|
||||
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
|
||||
}
|
||||
};
|
||||
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
|
||||
let _ = live.transform(xf);
|
||||
live
|
||||
}
|
||||
|
||||
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
|
||||
/// document by `xf`.
|
||||
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
|
||||
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
|
||||
/// inside it. Without parents, the nodes as they are.
|
||||
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
|
||||
let n = nodes.len();
|
||||
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
|
||||
let mut roots = vec![];
|
||||
for i in 0..n {
|
||||
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
|
||||
Some(of) => of.push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
}
|
||||
// Outermost first, so that backwards each path comes after those inside it.
|
||||
let mut order = roots.clone();
|
||||
let mut at = 0;
|
||||
while let Some(&i) = order.get(at) {
|
||||
order.extend(inside.get(i).into_iter().flatten());
|
||||
at += 1;
|
||||
}
|
||||
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
|
||||
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
|
||||
for &i in order.iter().rev() {
|
||||
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
|
||||
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
|
||||
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
|
||||
if let Some(slot) = built.get_mut(i) {
|
||||
*slot = Some(Arc::new(made));
|
||||
}
|
||||
}
|
||||
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
|
||||
// and they are kept all the same.)
|
||||
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
|
||||
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
|
||||
}
|
||||
|
||||
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
const C: &str = "imageTrace.make";
|
||||
let mut params = trace_params(p)?;
|
||||
@@ -763,12 +833,20 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
hidden.visible = false;
|
||||
children.push(Arc::new(hidden));
|
||||
}
|
||||
let mut traced = Vec::with_capacity(res.paths.len());
|
||||
for tp in res.paths {
|
||||
let path = tp.path.transformed(xf);
|
||||
let mut n = shape_node(d, path, None);
|
||||
// Shapes: a path the tracer found to be a shape is that live shape.
|
||||
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
|
||||
let shape = traced_shape(shape, xf);
|
||||
*path = shape.to_path();
|
||||
*live = Some(Box::new(shape));
|
||||
}
|
||||
// A library colour fills with the swatch's own colour (its model kept).
|
||||
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
|
||||
let paint = Paint::solid(color);
|
||||
// Gradients: a path the tracer found to be a linear gradient is filled with it.
|
||||
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
|
||||
n.appearance = match tp.stroke {
|
||||
// Traced lines: round ends and corners, as drawn lines have.
|
||||
Some(width) => Appearance {
|
||||
@@ -782,8 +860,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
.into(),
|
||||
None => fill_only(paint).into(),
|
||||
};
|
||||
children.push(Arc::new(n));
|
||||
traced.push(n);
|
||||
}
|
||||
children.extend(grouped(d, traced, &res.parents));
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
|
||||
@@ -836,12 +915,15 @@ fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = trace_targets(s, p)?;
|
||||
let ids = s.edit("Release Image Trace", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone();
|
||||
img.visible = true;
|
||||
out.extend(replace_roots(d, &[*g], vec![img])?);
|
||||
out.push(img.id);
|
||||
released.put(d, *g, vec![img])?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
|
||||
//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles).
|
||||
//! Paste takes from them (bitmaps, plain text, PDF, EPS and Windows metafiles).
|
||||
//!
|
||||
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
|
||||
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
|
||||
@@ -27,9 +27,12 @@ pub const BITMAP: &str = "image/*";
|
||||
/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it
|
||||
/// where the platform offers it).
|
||||
pub const EMF: &str = "image/emf";
|
||||
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
|
||||
/// beside, or instead of, a PDF (#1499).
|
||||
pub const EPS: &str = "application/postscript";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
|
||||
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
|
||||
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
|
||||
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
|
||||
/// address alone pastes as text, and nothing is fetched.
|
||||
@@ -67,13 +70,14 @@ fn only_images(html: &str) -> bool {
|
||||
pub const FILE_HEAD: usize = 4096;
|
||||
|
||||
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
|
||||
/// SVG, PDF, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
|
||||
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
|
||||
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
|
||||
let f = fileio::detect(name, head)?;
|
||||
let (asked, mime) = match f.id {
|
||||
"svg" => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"eps" => (EPS, EPS),
|
||||
"emf" | "wmf" => (EMF, EMF),
|
||||
_ if f.raster => (BITMAP, f.mime),
|
||||
_ => return None,
|
||||
@@ -167,6 +171,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
import_emf
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importEps",
|
||||
"Load EPS into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_eps
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -345,10 +358,26 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if vectorcraft_metafile::sniff(&bytes).is_none() {
|
||||
return Err(bad(C, "the data isn't an EMF or WMF picture"));
|
||||
}
|
||||
let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
import_picture(s, p, C, "Clipboard.emf", &bytes)
|
||||
}
|
||||
|
||||
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importEps";
|
||||
let bytes = data_param(p, C)?;
|
||||
if !vectorcraft_pdf::is_postscript(&bytes) {
|
||||
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
|
||||
}
|
||||
import_picture(s, p, C, "Clipboard.eps", &bytes)
|
||||
}
|
||||
|
||||
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
|
||||
/// opened (command `cmd`).
|
||||
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
loaded.doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the picture has no objects"));
|
||||
return Err(bad(cmd, "the picture has no objects"));
|
||||
}
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "warnings": loaded.warnings }))
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::node_path;
|
||||
use super::pathops::{Beside, node_path};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -163,10 +163,12 @@ fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
|
||||
}
|
||||
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
|
||||
let (mut all, mut removed) = (vec![], 0);
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
removed += pieces.is_empty() as usize;
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok((all, removed))
|
||||
})?;
|
||||
|
||||
@@ -10,6 +10,7 @@ use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection,
|
||||
use vectorcraft_geom::Vec2;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::Beside;
|
||||
use super::select::{selectable_ids, walk_selectable};
|
||||
use super::*;
|
||||
|
||||
@@ -488,8 +489,10 @@ fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
|
||||
s.menu.guides_locked = v;
|
||||
// Locked guides can't stay selected.
|
||||
if v && !s.doc()?.selection.guides.is_empty() {
|
||||
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
|
||||
// those go).
|
||||
let st = s.doc()?;
|
||||
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
|
||||
s.select(|_, sel| sel.guides.clear())?;
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
@@ -504,10 +507,13 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit("Clear Guides", |d, _| {
|
||||
d.guides.clear();
|
||||
let mut gone = Beside::replacing(d, &paths);
|
||||
for id in &paths {
|
||||
let _ = d.remove(*id);
|
||||
if d.node(*id).is_some() {
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
gone.finish(d)
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
|
||||
|
||||
use super::create::{add_look, add_node};
|
||||
use super::pathops::{Beside, remove_each};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -310,20 +311,25 @@ fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
|
||||
}
|
||||
|
||||
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
|
||||
/// it; none removes it). Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
let mut it = pieces.into_iter();
|
||||
let Some(first) = it.next() else {
|
||||
d.remove(id)?;
|
||||
cut.put(d, id, vec![])?;
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let mut ids = vec![id];
|
||||
for (k, pd) in it.enumerate() {
|
||||
let mut above = vec![];
|
||||
for pd in it {
|
||||
let n = sibling_with(d, id, pd)?;
|
||||
ids.push(d.insert(parent, idx + 1 + k, n)?);
|
||||
ids.push(n.id);
|
||||
above.push(n);
|
||||
}
|
||||
set_geometry(d, id, first)?;
|
||||
if !above.is_empty() {
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
cut.put(d, id, [n].into_iter().chain(above).collect())?;
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
@@ -733,7 +739,10 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
remove_anchors_of(d, sel, id, [(si, ai)])
|
||||
let mut emptied = Beside::replacing(d, &[id]);
|
||||
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?;
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObject": removed }))
|
||||
}
|
||||
@@ -752,18 +761,26 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let removed = s.edit("Remove Anchor Points", |d, sel| {
|
||||
let mut removed = 0;
|
||||
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
|
||||
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
|
||||
/// the path went.
|
||||
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
|
||||
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
|
||||
/// through `emptied`. True when the path went.
|
||||
fn remove_anchors_of(
|
||||
d: &mut Document,
|
||||
sel: &mut Selection,
|
||||
emptied: &mut Beside,
|
||||
id: NodeId,
|
||||
anchors: impl IntoIterator<Item = (usize, usize)>,
|
||||
) -> Result<bool> {
|
||||
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
|
||||
// The last first, so the indexes still to come stay valid.
|
||||
anchors.sort_unstable_by(|a, b| b.cmp(a));
|
||||
@@ -777,7 +794,7 @@ fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors:
|
||||
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
let gone = path.is_empty();
|
||||
if gone {
|
||||
d.remove(id)?;
|
||||
emptied.put(d, id, vec![])?;
|
||||
sel.remove(id);
|
||||
} else {
|
||||
sel.anchors.remove(&id);
|
||||
@@ -920,9 +937,11 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit("Knife", |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok(all)
|
||||
})?;
|
||||
@@ -960,9 +979,11 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let mut all = vec![];
|
||||
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
|
||||
for (id, pieces) in plan {
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
|
||||
}
|
||||
cut.finish(d)?;
|
||||
sel.set(all.iter().copied());
|
||||
Ok(all)
|
||||
})?;
|
||||
@@ -1027,17 +1048,46 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
|
||||
open.anchors.push(open.anchors[0]);
|
||||
m.push(false);
|
||||
open.closed = false;
|
||||
let mut res = smooth_open(&open.anchors, &m, tol);
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol);
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
r0.h_in = l.h_in;
|
||||
}
|
||||
return Some(SubPath::new(res, true));
|
||||
}
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false))
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false))
|
||||
}
|
||||
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
|
||||
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
|
||||
/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver
|
||||
/// is outside.
|
||||
fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> {
|
||||
const STEPS: u32 = 32;
|
||||
let step = 1.0 / f64::from(STEPS);
|
||||
// Back from the end in reach to the first sample outside, then halving to the crossing.
|
||||
let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?;
|
||||
let (mut lo, mut hi) = (outside, outside + step);
|
||||
for _ in 0..20 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if near(c.eval(mid)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
// A straight segment's parts stay straight, their handles on their ends.
|
||||
let straight = c.p1 == c.p0 && c.p2 == c.p3;
|
||||
let part = |range| {
|
||||
let s = c.subsegment(range);
|
||||
if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s }
|
||||
};
|
||||
(hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0)))
|
||||
}
|
||||
|
||||
/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments
|
||||
/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under
|
||||
/// it, and the rest of a long segment next to it stays as it was (#1512).
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec<Anchor> {
|
||||
let n = anchors.len();
|
||||
let mut runs = vec![];
|
||||
let mut i = 0;
|
||||
@@ -1058,8 +1108,27 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
|
||||
if hi - lo < 2 {
|
||||
continue;
|
||||
}
|
||||
let new = refit_run(&out[lo..=hi], tol);
|
||||
out.splice(lo..=hi, new);
|
||||
let mut piece = out[lo..=hi].to_vec();
|
||||
// The anchors outside the reach, kept with the part of their segment that is outside.
|
||||
let (mut head, mut tail) = (None, None);
|
||||
if hi > b
|
||||
&& let [.., inner, outer] = piece.as_mut_slice()
|
||||
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near)
|
||||
{
|
||||
inner.h_out = reach.p2;
|
||||
tail = Some(Anchor { h_in: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, reach.p1, far.p2);
|
||||
}
|
||||
if lo < a
|
||||
&& let [outer, inner, ..] = piece.as_mut_slice()
|
||||
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near)
|
||||
{
|
||||
inner.h_in = reach.p2;
|
||||
head = Some(Anchor { h_out: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
|
||||
}
|
||||
let new = refit_run(&piece, tol);
|
||||
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1256,9 +1325,7 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
|
||||
s.edit("Blob Brush", |d, sel| {
|
||||
*path_mut(d, keep)? = merged;
|
||||
for id in others {
|
||||
d.remove(id)?;
|
||||
}
|
||||
remove_each(d, &others)?;
|
||||
sel.set([keep]);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -132,7 +132,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
|
||||
cmd!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
["Edit"],
|
||||
None,
|
||||
"{dx?, dy?} duplicate the selection in place (offset optional)",
|
||||
has_selection,
|
||||
duplicate
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -558,10 +566,20 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them. The copies
|
||||
/// take new ids in the order of `roots`. Roots listed twice, or inside another root, are copied one
|
||||
/// at a time: each copy is made from the document as the copies before it left it.
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them
|
||||
/// ([`copy_above`]).
|
||||
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
|
||||
let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Copy `roots` directly above each original and return the copies' ids. `change` gets the
|
||||
/// document, each original and its copy before the copy goes in, and must leave the document's
|
||||
/// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside
|
||||
/// another root, are copied one at a time: each copy is made from the document as the copies
|
||||
/// before it left it.
|
||||
pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result<Vec<NodeId>> {
|
||||
let paths = d.index_paths(roots.iter().copied());
|
||||
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
@@ -570,7 +588,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
|
||||
for id in roots {
|
||||
let Some(n) = paths.get(id).and_then(|p| d.node_at(p)).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
change(d, &n, &mut c);
|
||||
copies.push((*id, vec![c]));
|
||||
}
|
||||
let out: Vec<NodeId> = copies.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
|
||||
@@ -582,12 +600,11 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio
|
||||
let Some((par, idx, _)) = d.position(*id) else { continue };
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
change(d, &n, &mut c);
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
out
|
||||
};
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +280,20 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() }))
|
||||
}
|
||||
|
||||
/// The ids of the effects `effect.setParams` with `p` edits, for the check of their parameters
|
||||
/// (none when `p` doesn't say which: running it reports why).
|
||||
pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
const C: &str = "effect.setParams";
|
||||
let (Ok(index), Ok(item), Ok(ids), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), appearance_targets(s, p), s.doc()) else {
|
||||
return vec![];
|
||||
};
|
||||
let effect = |id: &NodeId| {
|
||||
let ap = &st.doc.node(*id)?.appearance;
|
||||
Some(ap.effects_at(item.effects_item(ap, C).ok()?)?.get(index)?.id.clone())
|
||||
};
|
||||
ids.iter().filter_map(effect).collect()
|
||||
}
|
||||
|
||||
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.setParams";
|
||||
let index = index_param(p, "index", C)?;
|
||||
@@ -566,7 +580,7 @@ mod tests {
|
||||
for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] {
|
||||
assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "depth": 120}, "ids": [id.0], "item": null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art");
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
|
||||
@@ -382,7 +382,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
|
||||
// that silently lacks it.
|
||||
if f.raster && vectorcraft_render::decode_failures() > failed {
|
||||
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
|
||||
}
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
// themselves: SVG only when it outlines or embeds fonts).
|
||||
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
|
||||
|
||||
@@ -192,7 +192,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
@@ -263,6 +263,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
|
||||
}
|
||||
|
||||
@@ -381,18 +382,33 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result<Source<'a>> {
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, "document.open")?;
|
||||
open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p)
|
||||
// A relative path is the file in the working directory: its folder is where relative links
|
||||
// are found (a bare file name has no folder of its own).
|
||||
let path = src.path.map(super::absolute_path);
|
||||
open_bytes_with(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.clone(), p)
|
||||
}
|
||||
|
||||
pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src = source(p, "file.newFromTemplate")?;
|
||||
open_template(s, src.name, &src.bytes, src.path)
|
||||
let path = src.path.map(super::absolute_path);
|
||||
open_template(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.as_deref())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// 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
|
||||
/// nothing for them, so Open, Place and the Links panel say it (#1479). `None` for images that
|
||||
/// decode, and for bytes that aren't an image.
|
||||
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
use image::ImageDecoder as _;
|
||||
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = d.dimensions();
|
||||
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
|
||||
}
|
||||
|
||||
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
|
||||
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod eps;
|
||||
mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod load;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
|
||||
mod metafile;
|
||||
mod native;
|
||||
pub mod pdf;
|
||||
@@ -673,7 +674,7 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
|
||||
|
||||
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
|
||||
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print
|
||||
/// presets (imported).
|
||||
/// presets (imported), scripts (checked without running them) and plug-ins (installed).
|
||||
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All readable files", OPEN_EXTS))
|
||||
.chain(format_filters())
|
||||
@@ -682,6 +683,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
@@ -765,6 +767,20 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// The first `max` bytes of the file at `path` (all of it when it is shorter). Anything but a
|
||||
/// regular file (a folder, a pipe, a device) is refused, also through a link.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
may_read(path)?;
|
||||
if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
|
||||
return Err(EngineError::Other(format!("{path}: not a file")));
|
||||
}
|
||||
let mut out = vec![];
|
||||
std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
may_write(path)?;
|
||||
@@ -793,7 +809,7 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
may_write(path)?;
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
|
||||
let written = f.write_all(bytes).and_then(|()| f.sync_all());
|
||||
let written = f.write_all(bytes).and_then(|()| vectorcraft_format::sync_file(&f));
|
||||
drop(f);
|
||||
written.map_err(|e| {
|
||||
// Best effort: the partly written file is all there is to clean up.
|
||||
@@ -943,6 +959,11 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn read_file_head(path: &str, _: usize) -> Result<Vec<u8>> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
|
||||
@@ -159,9 +159,10 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(
|
||||
filters.len(),
|
||||
7 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, plug-ins"
|
||||
8 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
|
||||
);
|
||||
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
|
||||
@@ -163,3 +163,20 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
|
||||
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
|
||||
// the renderer can't decode it and draws nothing, which the export must say.
|
||||
let mut s = session(72.0, 36.0, 1);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4;
|
||||
png.truncate(cut);
|
||||
s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
|
||||
}
|
||||
@@ -35,7 +35,7 @@ use vectorcraft_render::effects;
|
||||
|
||||
use super::edit::roots_of;
|
||||
use super::menucmds::{MAX_PIXELS, isolated_doc, unique_key};
|
||||
use super::pathops::{node_path, outline_strokes, outline_strokes_under, shape_node};
|
||||
use super::pathops::{node_path, outline_strokes, outline_strokes_under, separate, shape_node};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1038,7 +1038,10 @@ pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Opt
|
||||
|
||||
fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||
let (mut vector, mut rasterized) = (0, 0);
|
||||
let mut ids = vec![];
|
||||
let (mut ids, mut flat) = (vec![], vec![]);
|
||||
// The groups replace their objects in one pass at the end. When an object is listed twice or
|
||||
// lies inside another listed one, each group replaces its objects as it is made instead.
|
||||
let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::<Vec<_>>());
|
||||
for f in plan.flat {
|
||||
// A group always has objects.
|
||||
let Some(&top) = f.roots.last() else { continue };
|
||||
@@ -1060,13 +1063,23 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
||||
children.push(Arc::new(n));
|
||||
vector += 1;
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let g = Node::group(d.alloc_id(), children);
|
||||
ids.push(d.insert(par, idx + 1, g)?);
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
ids.push(g.id);
|
||||
if one_pass {
|
||||
// The group replaces the topmost object, and the other objects are removed.
|
||||
flat.extend(f.roots.iter().filter(|r| **r != top).map(|r| (*r, vec![])));
|
||||
flat.push((top, vec![g]));
|
||||
} else {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
for r in &f.roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !flat.is_empty() {
|
||||
d.insert_beside(flat, true)?;
|
||||
}
|
||||
let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] };
|
||||
for mut id in plan.kept {
|
||||
if o.text_to_outlines {
|
||||
@@ -1113,9 +1126,8 @@ fn outline_texts_under(d: &mut Document, root: NodeId) -> Result<NodeId> {
|
||||
for t in texts {
|
||||
let Some(o) = outlined_text(&t) else { continue };
|
||||
let o = d.reid(&o);
|
||||
let (par, idx, _) = d.position(t.id).ok_or(EngineError::NoNode(t.id))?;
|
||||
let nid = d.insert(par, idx, o)?;
|
||||
d.remove(t.id)?;
|
||||
let nid = o.id;
|
||||
*d.node_mut(t.id).ok_or(EngineError::NoNode(t.id))? = o;
|
||||
if t.id == root {
|
||||
out = nid;
|
||||
}
|
||||
|
||||
@@ -232,12 +232,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some("Vector Halftone".into());
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
if !keep {
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
if keep {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
} else {
|
||||
d.wrap(&roots, |_| g)?;
|
||||
}
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
|
||||
@@ -234,23 +234,47 @@ pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Every layer and sublayer, depth first.
|
||||
fn all_layers(d: &Document) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
/// The number and the names of a document's layers and sublayers, from which [`NewLayers::make`]
|
||||
/// names and colors a new layer. [`NewLayers::of`] reads them in one walk of the document, and
|
||||
/// [`NewLayers::add`] counts each new layer as it goes in.
|
||||
pub(crate) struct NewLayers {
|
||||
count: usize,
|
||||
taken: HashSet<String>,
|
||||
}
|
||||
|
||||
impl NewLayers {
|
||||
pub(crate) fn of(d: &Document) -> Self {
|
||||
let mut layers = Self { count: 0, taken: HashSet::new() };
|
||||
d.walk(|n| layers.note(n));
|
||||
layers
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`, as [`Document::next_layer_name`]
|
||||
/// gives it) in the next layer color.
|
||||
pub(crate) fn make(&self, d: &mut Document, name: Option<&str>) -> Node {
|
||||
let name = name
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| (self.count + 1..).map(|i| format!("Layer {i}")).find(|name| !self.taken.contains(name)).unwrap_or_default());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((self.count % LAYER_COLORS.len()) as u8))
|
||||
}
|
||||
|
||||
/// Count `n`, a layer going into the document, and the layers inside it.
|
||||
pub(crate) fn add(&mut self, n: &Node) {
|
||||
n.walk(&mut |c| self.note(c));
|
||||
}
|
||||
|
||||
fn note(&mut self, n: &Node) {
|
||||
if n.is_layer() {
|
||||
out.push(n);
|
||||
self.count += 1;
|
||||
self.taken.extend(n.name.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`) in the next layer colour.
|
||||
pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
let n = all_layers(d).len();
|
||||
let name = name.map(str::to_string).unwrap_or_else(|| d.next_layer_name());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))
|
||||
NewLayers::of(d).make(d, name)
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
@@ -464,13 +488,12 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.delete", "no such rows"));
|
||||
}
|
||||
if st.doc.layers.iter().all(|l| ids.contains(&l.id)) {
|
||||
let listed: HashSet<NodeId> = ids.iter().copied().collect();
|
||||
if st.doc.layers.iter().all(|l| listed.contains(&l.id)) {
|
||||
return Err(EngineError::Other("a document needs at least one layer".into()));
|
||||
}
|
||||
s.edit("Delete Selection", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&ids)?;
|
||||
// The guides on the deleted layers go with them.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
sel.prune(d);
|
||||
@@ -491,18 +514,12 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("layer.duplicate", "no such rows"));
|
||||
}
|
||||
let copies = s.edit("Duplicate Selection", |d, _| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let mut c = d.reid(&n);
|
||||
super::edit::copy_above(d, &ids, |d, n, c| {
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
d.copy_layer_guides(&n, &c);
|
||||
d.copy_layer_guides(n, c);
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
})?;
|
||||
s.doc_mut()?.layer_rows = copies.clone();
|
||||
Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
@@ -579,14 +596,23 @@ fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = s.edit("Collect in New Layer", |d, sel| {
|
||||
// Where the topmost of them is: beside it in its layer, or on top of the layer holding
|
||||
// its group.
|
||||
let (parent, index) = match d.position(top) {
|
||||
Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1),
|
||||
_ => (d.layer_containing(top), usize::MAX),
|
||||
};
|
||||
let layer = make_layer(d, None);
|
||||
let lid = d.insert(parent, index, layer)?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(lid), usize::MAX)?;
|
||||
let beside = d.position(top).is_some_and(|(par, _, _)| par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)));
|
||||
let holder = d.layer_containing(top);
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
if beside {
|
||||
d.wrap(&ids, |rows| {
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = rows;
|
||||
}
|
||||
layer
|
||||
})?;
|
||||
} else {
|
||||
let rows = d.remove_many(&ids)?;
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = rows;
|
||||
}
|
||||
d.insert(holder, usize::MAX, layer)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
@@ -683,19 +709,20 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let label = if copy { "Duplicate Rows" } else { "Move Rows" };
|
||||
let moved = s.edit(label, |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let nodes = if copy {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
d.copy_layer_guides(&n, &c);
|
||||
}
|
||||
c
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
}
|
||||
nodes.push(Arc::new(c));
|
||||
}
|
||||
nodes
|
||||
} else {
|
||||
d.remove_many(&ids)?
|
||||
};
|
||||
let count = d.children(parent).map_or(0, Vec::len);
|
||||
let mut index = match anchor {
|
||||
None => count,
|
||||
@@ -708,11 +735,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
|
||||
index = index.max(1);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.push(d.insert(parent, index, n)?);
|
||||
index += 1;
|
||||
}
|
||||
let out: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
|
||||
let ch = d.children_mut(parent)?;
|
||||
let index = index.min(ch.len());
|
||||
ch.splice(index..index, nodes);
|
||||
sel.prune(d);
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(out)
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{make_layer, row_roots, rows_param};
|
||||
use super::layer::{NewLayers, row_roots, rows_param};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -234,28 +234,23 @@ fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// another) into layer `into`, keeping the document's stacking order, then delete the emptied
|
||||
/// layers.
|
||||
fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
let listed: HashSet<NodeId> = others.iter().copied().collect();
|
||||
let own: Vec<NodeId> =
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default();
|
||||
let mut moved = vec![];
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !listed.contains(c)).collect()).unwrap_or_default();
|
||||
let (mut moved, mut emptied) = (vec![], vec![]);
|
||||
for id in others {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if n.is_layer() {
|
||||
moved.extend(n.children().into_iter().flatten().map(|c| c.id));
|
||||
emptied.push(*id);
|
||||
} else {
|
||||
moved.push(*id);
|
||||
}
|
||||
}
|
||||
// Where everything is now: the merged layer stacks its contents in this order.
|
||||
let keys: HashMap<NodeId, Vec<usize>> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect();
|
||||
let mut taken = vec![];
|
||||
for id in &moved {
|
||||
taken.push(d.remove(*id)?);
|
||||
}
|
||||
for id in others {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
}
|
||||
let taken = d.remove_many(&moved)?;
|
||||
d.remove_many(&emptied)?;
|
||||
let ch = d.children_mut(Some(into))?;
|
||||
let mut all = std::mem::take(ch);
|
||||
all.extend(taken);
|
||||
@@ -312,9 +307,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
s.edit("Flatten Artwork", |d, sel| {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
d.remove_many(&hidden)?;
|
||||
// Hidden layers are discarded with their guides.
|
||||
d.drop_guides_of_deleted_layers(sel);
|
||||
merge_into(d, into, &others)?;
|
||||
@@ -352,39 +345,38 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| {
|
||||
// A group's objects go into sublayers of the layer holding it, where it was.
|
||||
let (parent, mut index) = if group {
|
||||
let (parent, index) = if group {
|
||||
let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) }
|
||||
} else {
|
||||
(id, 0)
|
||||
};
|
||||
let mut originals = vec![];
|
||||
for o in &objects {
|
||||
originals.push(Arc::unwrap_or_clone(d.remove(*o)?));
|
||||
}
|
||||
let originals = d.remove_many(&objects)?;
|
||||
if group {
|
||||
d.remove(id)?;
|
||||
}
|
||||
let mut new = vec![];
|
||||
let mut names = NewLayers::of(d);
|
||||
let mut layers = Vec::with_capacity(originals.len());
|
||||
let last = originals.len().saturating_sub(1);
|
||||
for k in 0..originals.len() {
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
let held: Vec<Node> = if build {
|
||||
let mut layer = names.make(d, None);
|
||||
let held: Vec<Arc<Node>> = if build {
|
||||
// Copies of the objects below and itself; the top layer keeps the originals.
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect()
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { Arc::new(d.reid(o)) }).collect()
|
||||
} else {
|
||||
originals.get(k).cloned().into_iter().collect()
|
||||
};
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
ch.extend(held.into_iter().map(Arc::new));
|
||||
ch.extend(held);
|
||||
}
|
||||
let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) };
|
||||
d.insert(Some(parent), at, layer)?;
|
||||
index = at.saturating_add(1);
|
||||
new.push(lid);
|
||||
names.add(&layer);
|
||||
layers.push(Arc::new(layer));
|
||||
}
|
||||
let new: Vec<NodeId> = layers.iter().map(|l| l.id).collect();
|
||||
let ch = d.children_mut(Some(parent))?;
|
||||
let at = index.min(ch.len());
|
||||
ch.splice(at..at, layers);
|
||||
sel.prune(d);
|
||||
Ok(new)
|
||||
})?;
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Links",
|
||||
[],
|
||||
None,
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image too large to draw, document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -1011,7 +1011,14 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (link, mut row) = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
(im.link.as_ref(), json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height }))
|
||||
let mut row = json!({ "format": format_label(&st.doc, im), "pixelWidth": im.width, "pixelHeight": im.height });
|
||||
// Only an image that could be past the decode allowance (at 16 bytes a pixel, the
|
||||
// most a decoder gives) has its header read for this.
|
||||
let large = im.width as u64 * im.height as u64 > fileio::MAX_RASTER_ALLOC / 16;
|
||||
if let Some(note) = st.doc.images.get(&im.key).filter(|_| large).and_then(|b| fileio::too_large_note(&b.bytes)) {
|
||||
row["note"] = json!(note);
|
||||
}
|
||||
(im.link.as_ref(), row)
|
||||
}
|
||||
NodeKind::PlacedDocument(p) => (Some(&p.link), document_row(p)),
|
||||
_ => continue,
|
||||
|
||||
@@ -18,6 +18,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -360,14 +361,11 @@ fn has_mesh(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
/// Wrap `roots` (paint order) into a new node built by `make`, placed where the topmost root was.
|
||||
fn wrap(d: &mut Document, sel: &mut Selection, roots: &[NodeId], make: impl FnOnce(NodeId, Vec<Arc<Node>>) -> Node) -> Result<NodeId> {
|
||||
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing to wrap".into()))?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, make(id, nodes))?;
|
||||
for r in roots {
|
||||
d.remove(*r)?;
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("nothing to wrap".into()));
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
d.wrap(roots, |nodes| make(id, nodes))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
}
|
||||
@@ -546,6 +544,7 @@ fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let blends = selected_of(s, is_blend);
|
||||
let (ids, spines, groups) = s.edit("Release Blend", |d, sel| {
|
||||
let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]);
|
||||
let mut released = Beside::replacing(d, &blends);
|
||||
for b in &blends {
|
||||
let Some(n) = d.node(*b).cloned() else { continue };
|
||||
let NodeKind::Blend { children, spec } = &n.kind else { continue };
|
||||
@@ -572,8 +571,9 @@ fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
groups.push(g.id);
|
||||
nodes = vec![g];
|
||||
}
|
||||
replace_with(d, *b, nodes)?;
|
||||
released.put(d, *b, nodes)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
if groups.is_empty() {
|
||||
sel.set(spines.iter().chain(&keys).copied());
|
||||
} else {
|
||||
@@ -1171,6 +1171,7 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let envs = selected_of(s, is_envelope);
|
||||
let ids = s.edit("Release Envelope", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &envs);
|
||||
for e in &envs {
|
||||
let Some(n) = d.node(*e).cloned() else { continue };
|
||||
let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue };
|
||||
@@ -1179,8 +1180,10 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
shape.id = d.alloc_id();
|
||||
nodes.push(shape);
|
||||
}
|
||||
out.extend(replace_with(d, *e, nodes)?);
|
||||
out.extend(nodes.iter().map(|c| c.id));
|
||||
released.put(d, *e, nodes)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Bounding Box, Rasterize, Crop Image, Mask (an image), Create Trim Marks, Convert to Shape and
|
||||
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -209,7 +210,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut hits: Vec<NodeId> = vec![];
|
||||
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
|
||||
// ancestor directly inside the layer.
|
||||
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
|
||||
for id in &roots {
|
||||
let Some(layer) = d.layer_of(*id) else { continue };
|
||||
// The selected object's ancestor directly inside the layer.
|
||||
@@ -218,12 +221,26 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let top = anc.get(pos + 1).copied().unwrap_or(*id);
|
||||
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
|
||||
let Some((_, idx, _)) = d.position(top) else { continue };
|
||||
for n in d.children(Some(layer)).into_iter().flatten().skip(idx + 1) {
|
||||
if roots.contains(&n.id) || hits.contains(&n.id) {
|
||||
selected.entry(layer).or_default().push((idx, b));
|
||||
}
|
||||
// The loop walks up each layer once. Each object that is not selected and overlaps a selected
|
||||
// object below it is locked (Lock) or hidden (Hide).
|
||||
let roots: HashSet<NodeId> = roots.into_iter().collect();
|
||||
let mut hits: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for (layer, mut below) in selected {
|
||||
below.sort_by_key(|(idx, _)| *idx);
|
||||
let mut below = below.into_iter().peekable();
|
||||
// `under` holds the bounds of the selected objects below the object at `i`.
|
||||
let mut under: Vec<Rect> = vec![];
|
||||
for (i, n) in d.children(Some(layer)).into_iter().flatten().enumerate() {
|
||||
while let Some((_, b)) = below.next_if(|(idx, _)| *idx < i) {
|
||||
under.push(b);
|
||||
}
|
||||
if under.is_empty() || roots.contains(&n.id) {
|
||||
continue;
|
||||
}
|
||||
if n.visual_bounds().is_some_and(|nb| nb.intersect(b).area() > 0.0 || nb.contains(b.center())) {
|
||||
hits.push(n.id);
|
||||
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
hits.insert(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,9 +460,10 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||||
let tmp = isolated_doc(&st.doc, nodes);
|
||||
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
let id = s.edit("Rasterize", |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
@@ -455,11 +473,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some((path, rule)) = clip {
|
||||
node = super::rasterfx::clip_group(d, path, rule, node);
|
||||
}
|
||||
let id = node.id;
|
||||
d.insert(par, idx + 1, node)?;
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
let id = d.wrap(&roots, |_| node)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -625,7 +639,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
|
||||
const C: &str = "object.cropImage";
|
||||
let st = s.doc()?;
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
let px = image::load_from_memory(&blob.bytes).map_err(|_| bad(C, "the image's pixels can't be read"))?.to_rgba8();
|
||||
// A CMYK image (kept as its ink amounts) has no alpha: nothing to cut.
|
||||
if blob.is_cmyk() {
|
||||
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
|
||||
}
|
||||
// Print rasters run to gigabytes decoded: the same allowance opening them has.
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let px = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?.to_rgba8();
|
||||
let (w, h) = px.dimensions();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (x, y, p) in px.enumerate_pixels() {
|
||||
@@ -818,7 +843,7 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let n = s.edit("Convert to Artboards", |d, sel| {
|
||||
let mut n = 0;
|
||||
let mut converted = vec![];
|
||||
for id in &roots {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
@@ -832,14 +857,14 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
});
|
||||
d.remove(*id)?;
|
||||
n += 1;
|
||||
converted.push(*id);
|
||||
}
|
||||
if n == 0 {
|
||||
if converted.is_empty() {
|
||||
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
|
||||
}
|
||||
d.remove_many(&converted)?;
|
||||
sel.clear();
|
||||
Ok(n)
|
||||
Ok(converted.len())
|
||||
})?;
|
||||
Ok(json!({ "artboards": n }))
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ pub mod printtiling;
|
||||
pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
pub mod scripts;
|
||||
mod select;
|
||||
pub(crate) mod shaper;
|
||||
pub(crate) mod slices;
|
||||
@@ -278,6 +279,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(printpresets::specs());
|
||||
v.extend(printtiling::specs());
|
||||
v.extend(plugin::specs());
|
||||
v.extend(scripts::specs());
|
||||
v.extend(cut::specs());
|
||||
v.extend(halftone::specs());
|
||||
v.extend(perspgrid::specs());
|
||||
|
||||
@@ -719,42 +719,39 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let id = s.edit("Make Compound Path", |d, sel| {
|
||||
let appearance = d.node(backmost).ok_or(EngineError::NoNode(backmost))?.appearance.clone();
|
||||
let mut paths: Vec<Arc<Node>> = vec![];
|
||||
d.node(backmost).ok_or(EngineError::NoNode(backmost))?;
|
||||
let mut units: Vec<Vec<Arc<Node>>> = vec![];
|
||||
let mut appearance = None;
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
match &n.kind {
|
||||
NodeKind::Path { .. } => paths.push(Arc::new(n)),
|
||||
NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()),
|
||||
NodeKind::Group { .. } => {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. }) {
|
||||
paths.push(Arc::new(c.clone()))
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => return Err(EngineError::Other("compound paths can only contain paths".into())),
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) {
|
||||
return Err(EngineError::Other("compound paths can only contain paths".into()));
|
||||
}
|
||||
compound_members(n, &mut appearance, &mut units);
|
||||
}
|
||||
let cid = d.alloc_id();
|
||||
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
|
||||
c.appearance = appearance;
|
||||
c.appearance = appearance.unwrap_or_default();
|
||||
let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?;
|
||||
// The paths in front run against the backmost one, so where they overlap it they cut holes
|
||||
// under the non-zero fill rule (#1197).
|
||||
// under the non-zero fill rule (#1197). A compound path's paths turn together, by its
|
||||
// first: its own holes (a letter's counters) stay holes.
|
||||
let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum());
|
||||
let back = paths.first().map_or(0.0, |p| direction(p));
|
||||
for (i, mut p) in paths.into_iter().enumerate() {
|
||||
let pn = Arc::make_mut(&mut p);
|
||||
pn.appearance = Appearance::default().into();
|
||||
let flip = i > 0 && back != 0.0 && direction(pn) == back;
|
||||
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
|
||||
*live = None;
|
||||
if flip {
|
||||
path.reverse();
|
||||
let first = |unit: &[Arc<Node>]| unit.first().map_or(0.0, |p| direction(p));
|
||||
let back = units.first().map_or(0.0, |u| first(u));
|
||||
for (i, unit) in units.into_iter().enumerate() {
|
||||
let flip = i > 0 && back != 0.0 && first(&unit) == back;
|
||||
for mut p in unit {
|
||||
let pn = Arc::make_mut(&mut p);
|
||||
pn.appearance = Appearance::default().into();
|
||||
if let NodeKind::Path { path, live, .. } = &mut pn.kind {
|
||||
*live = None;
|
||||
if flip {
|
||||
path.reverse();
|
||||
}
|
||||
}
|
||||
ch.push(p);
|
||||
}
|
||||
ch.push(p);
|
||||
}
|
||||
d.wrap(&ids, |_| c)?;
|
||||
sel.set([cid]);
|
||||
@@ -763,6 +760,20 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// The paths in `n` (a path, a compound path, or a group searched in depth) onto `out`, in paint
|
||||
/// order: each path alone, a compound path's paths together. Into `paint` goes the appearance of
|
||||
/// the backmost found so far: the path's own, or its compound path's, which is what painted it. A
|
||||
/// group has no paint of its own to give: a group of outlined type made a compound path that drew
|
||||
/// nothing.
|
||||
fn compound_members(n: &Node, paint: &mut Option<vectorcraft_doc::Shared<Appearance>>, out: &mut Vec<Vec<Arc<Node>>>) {
|
||||
match (&n.kind, n.children()) {
|
||||
(NodeKind::Path { .. }, _) => out.push(vec![Arc::new(n.clone())]),
|
||||
(NodeKind::Compound { .. }, Some(paths)) if !paths.is_empty() => out.push(paths.clone()),
|
||||
(_, children) => return children.into_iter().flatten().for_each(|c| compound_members(c, paint, out)),
|
||||
}
|
||||
paint.get_or_insert_with(|| n.appearance.clone());
|
||||
}
|
||||
|
||||
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
|
||||
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
|
||||
fn releases(n: &Node) -> bool {
|
||||
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection};
|
||||
|
||||
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
|
||||
use super::pathops::{Beside, separate};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -189,6 +190,9 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Several objects are grouped so they share one mask.
|
||||
let target = if let [one] = art {
|
||||
*one
|
||||
} else if separate(d, art) {
|
||||
let gid = d.alloc_id();
|
||||
d.wrap(art, |children| Node::group(gid, children))?
|
||||
} else {
|
||||
let last = art[art.len() - 1];
|
||||
let (par, idx, _) = d.position(last).ok_or(EngineError::NoNode(last))?;
|
||||
@@ -223,16 +227,16 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Leave mask editing first: its art goes back into the mask, which is released below.
|
||||
finish(d, sel);
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::above(d, &ids);
|
||||
for id in &ids {
|
||||
let Some(m) = d.node_mut(*id).and_then(|n| n.extras_mut().mask.take()) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
out.push(*id);
|
||||
for (k, part) in mask_parts(&m.art).iter().enumerate() {
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let art = d.reid(part);
|
||||
out.push(d.insert(par, idx + 1 + k, art)?);
|
||||
}
|
||||
// Fresh ids: copies of a masked object share the ids inside their mask art.
|
||||
let parts: Vec<Node> = mask_parts(&m.art).iter().map(|part| d.reid(part)).collect();
|
||||
out.extend(parts.iter().map(|p| p.id));
|
||||
released.put(d, *id, parts)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -136,10 +136,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val
|
||||
// Its guides go with it.
|
||||
let gone = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
|
||||
for id in &art {
|
||||
// Always there: none of the art is inside another of it.
|
||||
let _ = d.remove(*id);
|
||||
}
|
||||
d.remove_many(&art)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -161,13 +158,7 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec
|
||||
if let Some(id) = d.artboards.get(index).map(|a| a.id) {
|
||||
d.copy_artboard_guides(src.id, id, dv);
|
||||
}
|
||||
let mut copies = Vec::with_capacity(art.len());
|
||||
for id in art {
|
||||
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(Affine::translate(dv), sc);
|
||||
copies.push(d.insert(parent, at + 1, c)?);
|
||||
}
|
||||
let copies = super::edit::copy_above(d, art, |_, _, c| c.transform(Affine::translate(dv), sc))?;
|
||||
Ok((index, copies))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
//!
|
||||
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
|
||||
//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the
|
||||
//! command with its default. [`Session::execute`] itself takes what it is given, so the journal of
|
||||
//! an older version still replays.
|
||||
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
|
||||
//! what it is given, so the journal of an older version still replays.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -150,6 +152,12 @@ fn accepted(doc: &'static str, depth: usize) -> Option<Vec<&'static str>> {
|
||||
Some(keys)
|
||||
}
|
||||
|
||||
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
|
||||
/// its `…` entries name)?
|
||||
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
|
||||
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
|
||||
}
|
||||
|
||||
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
|
||||
fn distance(a: &str, b: &str) -> usize {
|
||||
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
|
||||
@@ -183,15 +191,56 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(keys) = accepted(doc, 0) else { return Ok(()) };
|
||||
let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { return Ok(()) };
|
||||
Err(bad(
|
||||
match given.keys().find(|k| !keys.contains(&k.as_str())) {
|
||||
Some(key) => Err(unknown(id, key, &keys)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
|
||||
/// them, or all of them.
|
||||
fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
bad(
|
||||
id,
|
||||
match nearest(key, &keys) {
|
||||
match nearest(key, keys) {
|
||||
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
|
||||
None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"),
|
||||
None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")),
|
||||
},
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of its
|
||||
/// default's type. Numbers out of range pass: the effect clamps them when it draws. An effect
|
||||
/// plug-in resolves its own against its manifest, and an unknown effect is the command's to report.
|
||||
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
|
||||
let defaults = info.defaults.as_object();
|
||||
let mut keys: Vec<&str> = defaults.into_iter().flat_map(|d| d.keys().map(String::as_str)).collect();
|
||||
// A description that doesn't parse names no more keys (the tests rule that out).
|
||||
for key in doc_keys(info.params).map(|d| d.keys).unwrap_or_default() {
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
for (key, v) in given {
|
||||
if !keys.contains(&key.as_str()) {
|
||||
return Err(unknown(id, key, &keys));
|
||||
}
|
||||
match defaults.and_then(|d| d.get(key)) {
|
||||
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
|
||||
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
|
||||
// (A colour is also taken as `[r, g, b]` or as a colour object.)
|
||||
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
|
||||
@@ -327,10 +376,16 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"webExport.settings",
|
||||
];
|
||||
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` included. A command the
|
||||
/// engine doesn't have passes: running it reports that.
|
||||
/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and
|
||||
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
|
||||
/// passes: running it reports that.
|
||||
pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
let Some(spec) = find_command(id) else { return Ok(()) };
|
||||
if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) =
|
||||
(id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params"))
|
||||
{
|
||||
check_effect_params(id, effect, given)?;
|
||||
}
|
||||
if UNCHECKED.contains(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -344,3 +399,18 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in
|
||||
/// this session's document (not as a step of a `command.batch`: what it edits is only there
|
||||
/// once the steps before it ran).
|
||||
pub fn check_params(&self, id: &str, params: &Value) -> Result<()> {
|
||||
check_params(id, params)?;
|
||||
if let ("effect.setParams", Some(given)) = (id, params.get("params")) {
|
||||
for effect in super::effectcmd::set_params_effects(self, params) {
|
||||
check_effect_params(id, &effect, given)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -63,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Join",
|
||||
["Object", "Path"],
|
||||
Some("Cmd+J"),
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path",
|
||||
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
|
||||
has_selection,
|
||||
join
|
||||
),
|
||||
@@ -299,6 +300,37 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
|
||||
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
|
||||
}
|
||||
|
||||
/// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which
|
||||
/// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many
|
||||
/// anchors went.
|
||||
fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
|
||||
let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6;
|
||||
let before = sp.anchors.len();
|
||||
// The closing pair of a closed path first: taking the last anchor moves no other index.
|
||||
if sp.closed
|
||||
&& before > 2
|
||||
&& picked(0)
|
||||
&& picked(before - 1)
|
||||
&& let [first, .., last] = sp.anchors.as_mut_slice()
|
||||
&& same(first, last)
|
||||
{
|
||||
first.h_in = last.h_in;
|
||||
sp.anchors.pop();
|
||||
}
|
||||
// Back to front, so the indices `picked` knows stay true for the anchors still to visit.
|
||||
for i in (1..sp.anchors.len()).rev() {
|
||||
if picked(i - 1)
|
||||
&& picked(i)
|
||||
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
|
||||
&& same(a, b)
|
||||
{
|
||||
a.h_out = b.h_out;
|
||||
sp.anchors.remove(i);
|
||||
}
|
||||
}
|
||||
before - sp.anchors.len()
|
||||
}
|
||||
|
||||
fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.join";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
@@ -311,6 +343,31 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => selected_paths(s)?,
|
||||
};
|
||||
// Direct-selected anchors lying on one another along a path (two corners brought together by
|
||||
// Average) become one anchor; the paths themselves are joined only when there are none.
|
||||
if p.get("ids").is_none() {
|
||||
let st = s.doc()?;
|
||||
let mut merged = vec![];
|
||||
for (id, picked) in &st.selection.anchors {
|
||||
let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
|
||||
let n: usize = path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)))).sum();
|
||||
if n > 0 {
|
||||
merged.push((*id, path));
|
||||
}
|
||||
}
|
||||
if !merged.is_empty() {
|
||||
let objects = st.selection.objects.clone();
|
||||
s.edit("Join", |d, sel| {
|
||||
for (id, path) in merged {
|
||||
*path_mut(d, id)? = path;
|
||||
}
|
||||
// The anchor indices changed: the paths stay selected, whole.
|
||||
sel.set(objects);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
// The ends to join, as (reverse the first path, reverse the second): the first path's end
|
||||
// must come last, the second's first.
|
||||
let ends = match p.get("ends") {
|
||||
@@ -466,6 +523,7 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let segments = st.selection.segments.clone();
|
||||
s.edit("Clear", |d, selection| {
|
||||
let mut survivors = vec![];
|
||||
let mut emptied = Beside::replacing(d, &sel.keys().copied().collect::<Vec<_>>());
|
||||
for (id, set) in &sel {
|
||||
let remove_node = {
|
||||
let path = path_mut(d, *id)?;
|
||||
@@ -499,11 +557,12 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
path.subpaths.is_empty()
|
||||
};
|
||||
if remove_node {
|
||||
d.remove(*id)?;
|
||||
emptied.put(d, *id, vec![])?;
|
||||
} else if segments.contains_key(id) {
|
||||
survivors.push(*id);
|
||||
}
|
||||
}
|
||||
emptied.finish(d)?;
|
||||
// Another Delete removes the remaining path after a segment cut. Anchor Delete keeps
|
||||
// its existing deselection behavior.
|
||||
selection.set(survivors);
|
||||
@@ -625,11 +684,12 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit("Cut Path", |d, sel| {
|
||||
let (mut all, mut picked) = (vec![], vec![]);
|
||||
let mut cut = Beside::replacing(d, &cuts.iter().map(|(id, ..)| *id).collect::<Vec<_>>());
|
||||
for (id, subs, starts) in cuts {
|
||||
// A path of one subpath becomes one path per piece (the first keeps the id); the
|
||||
// pieces of a compound shape stay subpaths of it.
|
||||
if d.node(id).and_then(|n| n.path_data()).is_some_and(|pd| pd.subpaths.len() == 1) {
|
||||
let ids = super::draw2::replace_with_pieces(d, id, subs.into_iter().map(PathData::single).collect())?;
|
||||
let ids = super::draw2::replace_with_pieces(d, &mut cut, id, subs.into_iter().map(PathData::single).collect())?;
|
||||
picked.extend(ids.iter().zip(&starts).filter(|(_, s)| **s).map(|(id, _)| (*id, (0, 0))));
|
||||
all.extend(ids);
|
||||
} else {
|
||||
@@ -638,6 +698,7 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
all.push(id);
|
||||
}
|
||||
}
|
||||
cut.finish(d)?;
|
||||
// Each cut leaves one of its two coincident anchors selected, so a drag pulls the ends apart.
|
||||
sel.clear();
|
||||
for (id, a) in picked {
|
||||
|
||||
@@ -286,6 +286,73 @@ fn shape_node_styled(d: &mut Document, path: PathData, style: &Style) -> Node {
|
||||
n
|
||||
}
|
||||
|
||||
/// Whether `ids` are separate objects, as [`Document::remove_many`] and [`Document::insert_beside`]
|
||||
/// take them: each one is in the tree and listed once, and none lies inside another listed one.
|
||||
pub(crate) fn separate(d: &Document, ids: &[NodeId]) -> bool {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
let mut found: Vec<_> = ids.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
found.len() == ids.len() && found.windows(2).all(|w| !w[1].starts_with(w[0]))
|
||||
}
|
||||
|
||||
/// Lists of nodes that go in place of objects, or directly above them; an empty list in place of
|
||||
/// an object removes it. When the objects are [`separate`], [`Beside::put`] holds each list and
|
||||
/// leaves the tree as it is, and [`Beside::finish`] inserts them all in one pass over the children
|
||||
/// of each parent ([`Document::insert_beside`]). Otherwise [`Beside::put`] inserts each list at
|
||||
/// once, where its object is at that moment.
|
||||
pub(crate) struct Beside {
|
||||
replace: bool,
|
||||
waiting: Option<Vec<(NodeId, Vec<Node>)>>,
|
||||
}
|
||||
|
||||
impl Beside {
|
||||
/// Lists that go in place of objects among `ids`.
|
||||
pub(crate) fn replacing(d: &Document, ids: &[NodeId]) -> Self {
|
||||
Beside { replace: true, waiting: separate(d, ids).then(Vec::new) }
|
||||
}
|
||||
|
||||
/// Lists that go directly above objects among `ids`.
|
||||
pub(crate) fn above(d: &Document, ids: &[NodeId]) -> Self {
|
||||
Beside { replace: false, waiting: separate(d, ids).then(Vec::new) }
|
||||
}
|
||||
|
||||
/// Put `nodes`, in their order, in place of object `id` or directly above it.
|
||||
pub(crate) fn put(&mut self, d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<()> {
|
||||
if let Some(waiting) = &mut self.waiting {
|
||||
waiting.push((id, nodes));
|
||||
return Ok(());
|
||||
}
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let at = if self.replace {
|
||||
d.remove(id)?;
|
||||
idx
|
||||
} else {
|
||||
idx + 1
|
||||
};
|
||||
for (k, n) in nodes.into_iter().enumerate() {
|
||||
d.insert(parent, at + k, n)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert the lists that [`Beside::put`] held.
|
||||
pub(crate) fn finish(self, d: &mut Document) -> Result<()> {
|
||||
match self.waiting {
|
||||
Some(waiting) if !waiting.is_empty() => Ok(d.insert_beside(waiting, self.replace)?),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `ids` through a [`Beside`], which removes them in one pass when they are [`separate`].
|
||||
pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> {
|
||||
let mut gone = Beside::replacing(d, ids);
|
||||
for id in ids {
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
gone.finish(d)
|
||||
}
|
||||
|
||||
// ---------- Pathfinder ----------
|
||||
|
||||
fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
@@ -305,7 +372,9 @@ fn pf_label(op: PathfinderOp) -> &'static str {
|
||||
|
||||
fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
let shape_mode =
|
||||
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||
let label = pf_label(op);
|
||||
@@ -314,10 +383,15 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let mut styles: Vec<Style> = vec![];
|
||||
let mut shapes: Vec<po::Shape> = vec![];
|
||||
let mut names: Vec<Option<String>> = vec![];
|
||||
let mut push = |mut path: PathData, rule: FillRule, st: Style, name: Option<String>| {
|
||||
let mut push = |mut path: PathData, rule: FillRule, mut st: Style, name: Option<String>| {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Trim, Merge and Crop remove any strokes (and Merge then merges by fill alone), as the
|
||||
// public Pathfinder documentation describes.
|
||||
if matches!(op, PathfinderOp::Trim | PathfinderOp::Merge | PathfinderOp::Crop) {
|
||||
st.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
|
||||
}
|
||||
if op == PathfinderOp::Divide && st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if !f.paint.is_none())) {
|
||||
// Filled open contours keep their implicit closing edge. A zero-area line
|
||||
// stays a cutter; normalised area also catches zero-signed-area self crossings.
|
||||
@@ -371,7 +445,6 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
if results.iter().all(|r| r.path.is_empty()) {
|
||||
return Err(EngineError::Other(format!("{label} produced an empty result")));
|
||||
}
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let new_node = if shape_mode {
|
||||
let r =
|
||||
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
|
||||
@@ -403,11 +476,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
Node::group(gid, children)
|
||||
};
|
||||
let nid = new_node.id;
|
||||
d.insert(par, idx + 1, new_node)?;
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
let nid = d.wrap(&roots, |_| new_node)?;
|
||||
sel.set([nid]);
|
||||
Ok(vec![nid])
|
||||
})?;
|
||||
@@ -433,7 +502,8 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
let mut paths = false;
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut lv = vec![];
|
||||
@@ -441,6 +511,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut made = vec![];
|
||||
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
|
||||
let Some((path, rule)) = node_path(l) else { continue };
|
||||
paths = true;
|
||||
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
|
||||
let off = po::offset_path(&base, delta, join, miter);
|
||||
if off.is_empty() {
|
||||
@@ -460,12 +531,16 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = d.alloc_id();
|
||||
Node::group(gid, made.into_iter().map(Arc::new).collect())
|
||||
};
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
out.push(d.insert(par, idx + 1, new)?);
|
||||
out.push(new.id);
|
||||
above.push((*id, vec![new]));
|
||||
}
|
||||
if out.is_empty() && paths {
|
||||
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(EngineError::Other("Offset Path: select paths".into()));
|
||||
}
|
||||
d.insert_beside(above, false)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
@@ -614,9 +689,8 @@ pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&
|
||||
let (mut replacement, mut changed) = (root, 0);
|
||||
for l in lv {
|
||||
let Some(new) = f(d, &l) else { continue };
|
||||
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
|
||||
let nid = d.insert(par, idx, new)?;
|
||||
d.remove(l.id)?;
|
||||
let nid = new.id;
|
||||
*d.node_mut(l.id).ok_or(EngineError::NoNode(l.id))? = new;
|
||||
changed += 1;
|
||||
if l.id == root {
|
||||
replacement = nid;
|
||||
@@ -840,12 +914,13 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "removed": 0 }));
|
||||
}
|
||||
s.edit("Clean Up", |d, _| {
|
||||
let mut gone = Beside::replacing(d, &found);
|
||||
for id in &found {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
gone.put(d, *id, vec![])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
gone.finish(d)
|
||||
})?;
|
||||
Ok(json!({ "removed": found.len() }))
|
||||
}
|
||||
@@ -885,6 +960,7 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
|
||||
});
|
||||
let mut out = vec![];
|
||||
let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat());
|
||||
for t in targets {
|
||||
let Some(n) = d.node(t).cloned() else { continue };
|
||||
let Some((tp, tr)) = node_path(&n) else { continue };
|
||||
@@ -893,14 +969,12 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if inside.is_empty() || outside.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (par, idx, _) = d.position(t).ok_or(EngineError::NoNode(t))?;
|
||||
d.remove(t)?;
|
||||
for (k, piece) in [outside, inside].into_iter().enumerate() {
|
||||
let node = shape_node(d, piece, Some(&n));
|
||||
out.push(d.insert(par, idx + k, node)?);
|
||||
}
|
||||
let pieces: Vec<Node> = [outside, inside].into_iter().map(|piece| shape_node(d, piece, Some(&n))).collect();
|
||||
out.extend(pieces.iter().map(|p| p.id));
|
||||
cut.put(d, t, pieces)?;
|
||||
}
|
||||
d.remove(cutter_id)?;
|
||||
cut.put(d, cutter_id, vec![])?;
|
||||
cut.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -18,6 +18,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -633,15 +634,12 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<No
|
||||
return Ok(json!({ "id": r.0 }));
|
||||
}
|
||||
let roots = roots(s, p)?;
|
||||
let Some(&top) = roots.last() else { return Err(EngineError::Other("select the art to repeat".into())) };
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("select the art to repeat".into()));
|
||||
}
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
|
||||
let id = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(id, NodeKind::Repeat(make(nodes))))?;
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
d.wrap(&roots, |nodes| Node::new(id, NodeKind::Repeat(make(nodes))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -682,15 +680,14 @@ fn repeat_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let reps = selected_repeats(s);
|
||||
let ids = s.edit("Release Repeat", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = Beside::replacing(d, &reps);
|
||||
for r in &reps {
|
||||
let Some(n) = d.node(*r).cloned() else { continue };
|
||||
let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
d.remove(*r)?;
|
||||
for (k, c) in n.children().into_iter().flatten().enumerate() {
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, (**c).clone())?;
|
||||
}
|
||||
let art: Vec<Node> = n.children().into_iter().flatten().map(|c| (**c).clone()).collect();
|
||||
out.extend(art.iter().map(|c| c.id));
|
||||
released.put(d, *r, art)?;
|
||||
}
|
||||
released.finish(d)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
|
||||
@@ -183,7 +183,8 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
|
||||
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link, board: None });
|
||||
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||
}
|
||||
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
||||
let (mut doc, warnings) = match format.id {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `script.check`: a script written for the reference app (`.jsx`, `.jsxinc`, `.js`, or a compiled
|
||||
//! `.jsxbin`) checked without running any of it, by `vectorcraft-scriptcheck`: what kind of file it
|
||||
//! is, what it needs, its directives and includes, the platform features and document-model member
|
||||
//! chains it uses, the menu commands it runs, what it does outside the document, and its problems.
|
||||
//! With a path and `includes`, the files its `#include` lines name are read from inside its folder,
|
||||
//! each through the same checks as every other file a command reads.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report};
|
||||
|
||||
/// The ids a reply can hold, for the windows that show it.
|
||||
pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The extensions of the scripts File › Open shows a check for.
|
||||
pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "script.check",
|
||||
"Check Script",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)",
|
||||
always,
|
||||
check
|
||||
)]
|
||||
}
|
||||
|
||||
/// The folder a script is in, which its included files are read from.
|
||||
struct Folder {
|
||||
dir: std::path::PathBuf,
|
||||
/// The folder with its links followed (none where the file system can't tell).
|
||||
real: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
/// The folder of the script at `path`.
|
||||
fn of(path: &str) -> Self {
|
||||
let dir = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
||||
Folder { real: std::fs::canonicalize(dir).ok(), dir: dir.to_path_buf() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Loader for Folder {
|
||||
fn load(&mut self, path: &str, max: usize) -> Loaded {
|
||||
let file = self.dir.join(path);
|
||||
// A link that leads out of the script's folder isn't followed.
|
||||
if let (Some(real), Ok(target)) = (&self.real, std::fs::canonicalize(&file))
|
||||
&& !target.starts_with(real)
|
||||
{
|
||||
return Loaded::Refused;
|
||||
}
|
||||
let file = file.to_string_lossy();
|
||||
if crate::file_access::check_read(&file).is_err() {
|
||||
return Loaded::Refused;
|
||||
}
|
||||
super::fileio::read_file_head(&file, max).map_or(Loaded::Missing, Loaded::Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is
|
||||
/// decoded.
|
||||
fn decode_head(b64: &str, max: usize) -> Option<Vec<u8>> {
|
||||
let chars = max.div_ceil(3).saturating_mul(4);
|
||||
let mut kept = 0usize;
|
||||
let end = b64
|
||||
.bytes()
|
||||
.position(|b| {
|
||||
kept += usize::from(!b.is_ascii_whitespace());
|
||||
kept > chars
|
||||
})
|
||||
.unwrap_or(b64.len());
|
||||
vectorcraft_format::base64_decode(b64.get(..end)?)
|
||||
}
|
||||
|
||||
fn check(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "script.check";
|
||||
let limits = Limits::default();
|
||||
// One byte more than a file may have shows that the file is too large.
|
||||
let max = limits.file_bytes.saturating_add(1);
|
||||
let path = str_param(p, "path");
|
||||
let bytes: std::borrow::Cow<[u8]> = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => super::fileio::read_file_head(path, max)?.into(),
|
||||
(None, Some(text), _) => text.as_bytes().get(..max).unwrap_or(text.as_bytes()).into(),
|
||||
(None, None, Some(b64)) => decode_head(b64, max).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
let name = super::fileio::file_name(str_param(p, "name").or(path).unwrap_or_default());
|
||||
// With a path, the file is checked under its own name, which its includes are followed from;
|
||||
// `name` names the reply.
|
||||
let file = path.map(super::fileio::file_name).unwrap_or_else(|| name.clone());
|
||||
let mut folder = path.filter(|_| bool_or(p, "includes", false)).map(Folder::of);
|
||||
let report = vectorcraft_scriptcheck::check(&file, &bytes, folder.as_mut().map(|f| f as &mut dyn Loader), &limits);
|
||||
Ok(report_json(&name, &report))
|
||||
}
|
||||
|
||||
/// Object `v` with `key: value` added when there is a value.
|
||||
fn with(mut v: Value, key: &str, value: Option<impl serde::Serialize>) -> Value {
|
||||
if let (Some(o), Some(x)) = (v.as_object_mut(), value) {
|
||||
o.insert(key.into(), json!(x));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The reply for report `r` on the script `name`.
|
||||
fn report_json(name: &str, r: &Report) -> Value {
|
||||
let targets: Vec<Value> = r
|
||||
.targets
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let v = json!({"value": t.value, "app": t.app, "line": t.line});
|
||||
let v = with(v, "instance", t.instance.as_deref());
|
||||
let v = with(v, "version", t.version.as_deref());
|
||||
let v = with(v, "locale", t.locale.as_deref());
|
||||
with(v, "file", t.file.as_deref())
|
||||
})
|
||||
.collect();
|
||||
let directives: Vec<Value> = r
|
||||
.directives
|
||||
.iter()
|
||||
.map(|d| with(json!({"name": d.name, "value": d.value, "line": d.line, "form": d.form}), "file", d.file.as_deref()))
|
||||
.collect();
|
||||
let includes: Vec<Value> = r
|
||||
.includes
|
||||
.iter()
|
||||
.map(|i| with(with(json!({"value": i.value, "line": i.line, "status": i.status.id()}), "path", i.path.as_deref()), "file", i.file.as_deref()))
|
||||
.collect();
|
||||
let features: Vec<Value> = r.features.iter().map(|f| json!({"id": f.id, "count": f.count, "lines": f.lines})).collect();
|
||||
let members: Vec<Value> = r.members.iter().map(|m| json!({"path": m.name, "count": m.count})).collect();
|
||||
let types: Vec<Value> = r.types.iter().map(|t| json!({"name": t.name, "count": t.count})).collect();
|
||||
let strings: Vec<Value> = r.menu_strings.iter().map(|s| json!({"value": s.value, "count": s.count, "lines": s.lines})).collect();
|
||||
let outside: Vec<Value> = r
|
||||
.outside
|
||||
.iter()
|
||||
.map(|o| with(json!({"id": o.id, "count": o.count, "lines": o.lines}), "modes", Some(&o.modes).filter(|m| !m.is_empty())))
|
||||
.collect();
|
||||
let problems: Vec<Value> = r
|
||||
.problems
|
||||
.iter()
|
||||
.map(|p| with(with(with(json!({"code": p.code}), "line", p.line), "file", p.file.as_deref()), "detail", p.detail.as_deref()))
|
||||
.collect();
|
||||
json!({
|
||||
"name": name,
|
||||
"kind": r.kind.id(),
|
||||
"needs": r.needs.iter().map(|n| n.id()).collect::<Vec<_>>(),
|
||||
"lines": r.lines,
|
||||
"targets": targets,
|
||||
"directives": directives,
|
||||
"includes": includes,
|
||||
"features": features,
|
||||
"dom": {"members": members, "types": types},
|
||||
"menuCommands": {"strings": strings, "dynamic": r.menu_dynamic},
|
||||
"outside": outside,
|
||||
"problems": problems,
|
||||
"truncated": r.truncated,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Shaper compositions are groups of generated fills/strokes with transparent, editable source
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::pathops::Beside;
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
@@ -380,7 +381,10 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
ids.push(n.id)
|
||||
}
|
||||
});
|
||||
for id in ids {
|
||||
// Groups whose source art is all gone are removed through `emptied`, which is made when the
|
||||
// first one is found.
|
||||
let mut emptied: Option<Beside> = None;
|
||||
for &id in &ids {
|
||||
let Some(current) = d.node(id).cloned() else { continue };
|
||||
let Some(old) = before.node(id) else { continue };
|
||||
let src = sources(¤t);
|
||||
@@ -395,7 +399,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
if src.is_empty() {
|
||||
d.remove(id)?;
|
||||
emptied.get_or_insert_with(|| Beside::replacing(d, &ids)).put(d, id, vec![])?;
|
||||
continue;
|
||||
}
|
||||
let map = map_of(&src)?;
|
||||
@@ -494,7 +498,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
*ch = children;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
emptied.map_or(Ok(()), |e| e.finish(d))
|
||||
}
|
||||
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
@@ -670,9 +674,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut group = Node::group(id, children);
|
||||
group.name = Some("Shaper Group".into());
|
||||
group.extras_mut().shaper = Some(Box::new(spec));
|
||||
for id in &roots {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&roots)?;
|
||||
d.insert(parent, index.saturating_sub(earlier), group)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
|
||||
@@ -230,7 +230,12 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>
|
||||
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
|
||||
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
|
||||
return Err(bad(cmd, format!("unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh; prefix - to turn off)")));
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs; prefix - to turn off)"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
|
||||
}
|
||||
|
||||
@@ -578,6 +578,11 @@ fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `id` a path made into a guide (View → Guides → Make Guides)?
|
||||
pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool {
|
||||
d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. }))
|
||||
}
|
||||
|
||||
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
@@ -1295,16 +1300,35 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
/// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back.
|
||||
fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec<NodeId>) -> std::result::Result<(), String> {
|
||||
let Some(d) = self.active_mut() else { return (spec.enabled)(self) };
|
||||
let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() });
|
||||
let r = (spec.enabled)(self);
|
||||
if let Some(d) = self.active_mut() {
|
||||
d.selection = old;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
|
||||
// passes that its command's description doesn't name fails the test.
|
||||
#[cfg(test)]
|
||||
cmd::params::check_params(id, params)?;
|
||||
self.check_params(id, params)?;
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() { cmd::has_doc(self) } else { (spec.enabled)(self) };
|
||||
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
|
||||
cmd::has_doc(self)
|
||||
} else if let Some(ids) = given_ids(spec.params, params) {
|
||||
// Objects given as `ids` stand in for the selection: the command is enabled as it is
|
||||
// with them selected (#1575).
|
||||
self.enabled_with_selection(spec, ids)
|
||||
} else {
|
||||
(spec.enabled)(self)
|
||||
};
|
||||
if let Err(why) = enabled {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
@@ -1482,10 +1506,18 @@ impl Session {
|
||||
/// Change only the selection (not an undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let guides_locked = self.menu.guides_locked;
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
|
||||
if guides_locked {
|
||||
let locked: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| is_guide_path(&st.doc, *id)).collect();
|
||||
for id in locked {
|
||||
st.selection.remove(id);
|
||||
}
|
||||
}
|
||||
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
|
||||
// the reference app: new art then goes beside it.
|
||||
if st.selection.objects != before
|
||||
@@ -1757,6 +1789,8 @@ mod tests_halftone;
|
||||
#[cfg(test)]
|
||||
mod tests_history_mem;
|
||||
#[cfg(test)]
|
||||
mod tests_ids_without_selection;
|
||||
#[cfg(test)]
|
||||
mod tests_inline;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
@@ -1883,6 +1917,8 @@ mod tests_saveoptions;
|
||||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_scripts;
|
||||
#[cfg(test)]
|
||||
mod tests_shaper;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
@@ -1942,3 +1978,10 @@ mod tests_widthprofiles;
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
||||
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
|
||||
/// key callers outside the app may pass it, [`cmd::params::takes_key`]).
|
||||
fn given_ids(description: &'static str, params: &Value) -> Option<Vec<NodeId>> {
|
||||
let ids = cmd::ids_param(params, "ids").filter(|ids| !ids.is_empty())?;
|
||||
cmd::params::takes_key(description, "ids").then_some(ids)
|
||||
}
|
||||
@@ -1596,6 +1596,59 @@ fn inspect_reports_compound_paint_and_blend() {
|
||||
assert!(plain.get("blend").is_none(), "Normal is omitted: {plain}");
|
||||
}
|
||||
|
||||
/// A compound path made of a group (outlined type is one) takes the paint of the backmost path in
|
||||
/// it, not the group's own (none): it drew nothing. A compound path in the group gives the paint
|
||||
/// that painted its paths.
|
||||
#[test]
|
||||
fn compound_of_a_group_takes_its_backmost_paths_paint() {
|
||||
let paint = |s: &Session, id: u64| (*s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance).clone();
|
||||
let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let b = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let red = paint(&s, a.0);
|
||||
assert_ne!(red, paint(&s, b.0));
|
||||
assert_ne!(red, Default::default());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
made(&mut s, "object.group");
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
assert_eq!(paint(&s, c), red, "the backmost path's paint");
|
||||
// Grouped again and joined with a path in front: the paint the compound path gave its paths.
|
||||
let g = made(&mut s, "object.group");
|
||||
let front = rect(&mut s, 20.0, 20.0, 5.0, 5.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g, front.0]})).unwrap();
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
assert_eq!(paint(&s, c), red);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap().len(), 3);
|
||||
}
|
||||
/// Compound paths joined into one keep their own holes: each one's paths turn together, so the
|
||||
/// counters of outlined letters stay open (they filled in when every path was turned alone).
|
||||
#[test]
|
||||
fn joined_compound_paths_keep_their_holes() {
|
||||
let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut s = session();
|
||||
let mut rings = vec![];
|
||||
for x in [0.0, 100.0, 200.0] {
|
||||
let (outer, inner) = (rect(&mut s, x, 0.0, 40.0, 40.0), rect(&mut s, x + 10.0, 10.0, 10.0, 10.0));
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
rings.push(made(&mut s, "object.compoundPath.make"));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": rings})).unwrap();
|
||||
made(&mut s, "object.group");
|
||||
let c = made(&mut s, "object.compoundPath.make");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let turns: Vec<f64> =
|
||||
d.node(NodeId(c)).unwrap().children().unwrap().iter().map(|p| p.path_data().unwrap().subpaths[0].signed_area().signum()).collect();
|
||||
assert_eq!(turns.len(), 6);
|
||||
for ring in turns.chunks(2) {
|
||||
assert_eq!(ring[0], -ring[1], "a hole runs against its outline: {turns:?}");
|
||||
}
|
||||
// The rings in front run against the backmost one (#1197).
|
||||
assert_eq!((turns[2], turns[4]), (-turns[0], -turns[0]), "{turns:?}");
|
||||
}
|
||||
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
|
||||
/// the same fields the full-tree summary carries, without serializing the whole tree.
|
||||
#[test]
|
||||
|
||||
@@ -398,6 +398,25 @@ fn new_symbol_replaces_selection_with_instance_of_same_bounds() {
|
||||
assert_eq!(s.doc().unwrap().doc.symbols.len(), 1);
|
||||
}
|
||||
|
||||
/// New Symbol given `ids` that list an object and then the group that holds it puts the instance
|
||||
/// where the group was and removes both, as removing one object at a time does.
|
||||
#[test]
|
||||
fn new_symbol_of_an_object_and_the_group_that_holds_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||||
select(&mut s, &[a, b]);
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let c = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, group.0]})).unwrap();
|
||||
let inst = NodeId(r["id"].as_u64().unwrap());
|
||||
let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [inst, c]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [inst]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_and_break_link_round_trip_geometry() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -671,6 +671,108 @@ fn image_trace_make_places_paths_over_the_image() {
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
}
|
||||
|
||||
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
|
||||
/// circle as its traced path; without the option it is a plain path.
|
||||
#[test]
|
||||
fn image_trace_shapes_makes_live_shapes() {
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "shapes": true}})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let paths = traced_paths(&n);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let NodeKind::Path { path, live: Some(live), .. } = &paths[0].kind else { panic!("not a live shape: {:?}", paths[0].kind) };
|
||||
let vectorcraft_doc::LiveShape::Ellipse { w, h, .. } = **live else { panic!("{live:?}") };
|
||||
// A circle of r = 30 px, drawn twice the size.
|
||||
assert!(w == h && (w - 60.0).abs() < 0.75, "{w} × {h}");
|
||||
let bb = path.bounds().unwrap();
|
||||
assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5 && (bb.width() - 120.0).abs() < 1.5, "{bb:?}");
|
||||
assert_eq!(*path, live.to_path());
|
||||
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
|
||||
// Off, as documents saved before have it: a path.
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
|
||||
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
assert!(matches!(traced_paths(&n)[0].kind, NodeKind::Path { live: None, .. }));
|
||||
}
|
||||
|
||||
/// Auto Grouping: a ring and the dot in its hole are a group (the ring first), the square beside
|
||||
/// them is not in it; the picture is the same as without, and the group traces again, expands
|
||||
/// and releases as any other.
|
||||
#[test]
|
||||
fn image_trace_auto_grouping_groups_what_is_inside() {
|
||||
let art = vectorcraft_trace::Raster::from_fn(120, 80, |x, y| {
|
||||
let d = (x as f64 + 0.5 - 40.0).hypot(y as f64 + 0.5 - 40.0);
|
||||
if d <= 6.0 || (90..110).contains(&x) && (30..50).contains(&y) {
|
||||
[200, 0, 0, 255]
|
||||
} else if (16.0..=30.0).contains(&d) {
|
||||
[0, 0, 160, 255]
|
||||
} else {
|
||||
[255, 255, 255, 255]
|
||||
}
|
||||
});
|
||||
let make = |grouping: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": grouping}})).unwrap();
|
||||
assert_eq!(r["paths"], 3);
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
(s, g)
|
||||
};
|
||||
let (mut s, g) = make(true);
|
||||
let items = traced_paths(&node(&s, g));
|
||||
assert_eq!(items.len(), 2);
|
||||
let group = items.iter().find(|n| matches!(n.kind, NodeKind::Group { .. })).expect("a group");
|
||||
let inside = group.children().unwrap();
|
||||
assert_eq!(inside.len(), 2);
|
||||
assert_eq!(inside[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 160)));
|
||||
assert_eq!(inside[1].appearance.fill_paint(), Paint::solid(Color::rgb8(200, 0, 0)));
|
||||
assert!(matches!(items.iter().find(|n| n.id != group.id).unwrap().kind, NodeKind::Path { .. }));
|
||||
// The same picture as without.
|
||||
let (flat, fg) = make(false);
|
||||
assert_eq!(traced_paths(&node(&flat, fg)).len(), 3);
|
||||
let shot = |s: &Session| {
|
||||
let img = render_doc(s, true);
|
||||
(0..120).flat_map(|x| (0..80).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(shot(&s), shot(&flat));
|
||||
// Traced again without the option, it is flat again; expanded, the groups stay.
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).len(), 3);
|
||||
s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": true}})).unwrap();
|
||||
let r = s.execute("imageTrace.expand", &json!({})).unwrap();
|
||||
let expanded = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert_eq!(expanded.children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// Gradients: an image that is one linear gradient is one path filled with it, the gradient's line
|
||||
/// where the image lies; without the option it is bands of flat colour, as saved documents have it.
|
||||
#[test]
|
||||
fn image_trace_gradients_fills_with_a_gradient() {
|
||||
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
|
||||
let t = (f64::from(x) + 0.5) / 100.0;
|
||||
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
|
||||
});
|
||||
let make = |gradients: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
|
||||
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": gradients}})).unwrap();
|
||||
traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())))
|
||||
};
|
||||
let paths = make(true);
|
||||
assert_eq!(paths.len(), 1);
|
||||
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
|
||||
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Linear);
|
||||
assert_eq!(g.gradient.stops.len(), 2);
|
||||
// Level, from the image's left edge (x = 50) to its right one (x = 250), at any height.
|
||||
let geom = g.geom.as_ref().unwrap();
|
||||
let (a, b) = if geom.start.x < geom.end.x { (geom.start, geom.end) } else { (geom.end, geom.start) };
|
||||
assert!((a.x - 50.0).abs() < 3.0 && (b.x - 250.0).abs() < 3.0 && (a.y - b.y).abs() < 2.0, "{geom:?}");
|
||||
let flat = make(false);
|
||||
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_release_and_expand() {
|
||||
let mut s = session();
|
||||
@@ -795,7 +897,8 @@ fn image_trace_presets_query_and_errors() {
|
||||
let mut s = session();
|
||||
let r = s.execute("imageTrace.presets", &json!({})).unwrap();
|
||||
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names.len(), 13);
|
||||
assert_eq!(names.len(), 15);
|
||||
assert!(names.contains(&"Auto Color") && names.contains(&"Outline"));
|
||||
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray") && names.contains(&"Flat Logo"));
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image");
|
||||
|
||||
@@ -412,6 +412,29 @@ fn smooth_tool_reduces_jagged_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
|
||||
}
|
||||
|
||||
/// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight
|
||||
/// sides next to the bump stay straight lines from their far ends, which keep their place.
|
||||
#[test]
|
||||
fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> =
|
||||
[(50.0, 200.0), (190.0, 200.0), (200.0, 192.0), (210.0, 200.0), (350.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y})).to_vec();
|
||||
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let before = path(&s, id);
|
||||
drag(&mut s, "smooth", &[(195.0, 194.0), (205.0, 194.0)], Mods::default());
|
||||
let p = path(&s, id);
|
||||
assert_ne!(p, before, "the bump is smoothed");
|
||||
let a = &p.subpaths[0].anchors;
|
||||
let (first, second, last) = (a[0], a[1], a[a.len() - 1]);
|
||||
assert!(first.p == Point::new(50.0, 200.0) && !first.has_out() && !second.has_in(), "the left side is a line still: {first:?} {second:?}");
|
||||
assert!(last.p == Point::new(350.0, 200.0) && !last.has_in() && !a[a.len() - 2].has_out(), "and the right one: {last:?}");
|
||||
// Every other anchor is within the tool's reach of the drag (12 px either way).
|
||||
for x in &a[1..a.len() - 1] {
|
||||
assert!((182.0..=218.0).contains(&x.p.x) && (190.0..=201.0).contains(&x.p.y), "{x:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_tool_joins_two_lines() {
|
||||
let mut s = session();
|
||||
@@ -1141,6 +1164,31 @@ fn the_pen_continues_any_open_path_and_joins_another() {
|
||||
assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]);
|
||||
}
|
||||
|
||||
/// #1533: two corners of a rectangle brought together by Average become one anchor on Join, in
|
||||
/// one undo step; anchors that aren't on one another leave Join as it was.
|
||||
#[test]
|
||||
fn join_merges_the_selected_anchors_average_brought_together() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 100.0, 100.0, 100.0, 80.0);
|
||||
let pts = |s: &Session| path(s, r).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::<Vec<_>>();
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
|
||||
assert!(s.execute("path.join", &json!({})).is_err(), "apart: nothing to merge, and the path is closed");
|
||||
s.execute("path.average", &json!({"axis": "both"})).unwrap();
|
||||
s.execute("path.join", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s), [(150.0, 100.0), (200.0, 180.0), (100.0, 180.0)]);
|
||||
assert!(path(&s, r).subpaths[0].closed);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert!(sel.objects == [r] && sel.anchors.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s).len(), 4);
|
||||
// The closing pair: the first and last anchors of a closed path.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 3]], "mode": "set"})).unwrap();
|
||||
s.execute("path.average", &json!({"axis": "both"})).unwrap();
|
||||
let before = pts(&s);
|
||||
s.execute("path.join", &json!({})).unwrap();
|
||||
assert_eq!(pts(&s), before[..3]);
|
||||
}
|
||||
|
||||
/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors.
|
||||
#[test]
|
||||
fn join_takes_the_paths_and_the_ends_to_join() {
|
||||
|
||||
@@ -310,6 +310,23 @@ fn flatten_is_one_undo_step() {
|
||||
assert!(!exists(&s, a));
|
||||
}
|
||||
|
||||
/// Flattening a document for export in which two objects have the same id (a damaged file)
|
||||
/// removes both objects and puts one group in their place.
|
||||
#[test]
|
||||
fn flattening_objects_that_share_an_id_removes_both() {
|
||||
let _settings = settings_lock();
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000");
|
||||
let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff");
|
||||
run(&mut s, "transparency.set", json!({"ids": [a, b], "opacity": 50}));
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.node_mut(NodeId(b)).unwrap().id = NodeId(a);
|
||||
let flat = crate::cmd::flatten::flatten_document(&doc, &crate::cmd::FlattenOptions::default()).unwrap().unwrap();
|
||||
let art = flat.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "one group where the two objects were");
|
||||
assert!(matches!(art[0].kind, NodeKind::Group { .. }) && art[0].id != NodeId(a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_without_transparency_stay_unless_outlined() {
|
||||
let _settings = settings_lock();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! #1575: commands that take `ids` work on the objects given, with nothing selected; the
|
||||
//! selection is left as it was.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn doc_with_rect() -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_stand_in_for_the_selection() {
|
||||
for (cmd, params) in [
|
||||
("object.repeat.radial", json!({})),
|
||||
("edit.colors.invert", json!({})),
|
||||
("object.pattern.make", json!({"name": "P"})),
|
||||
("symbol.new", json!({"name": "S"})),
|
||||
("object.mesh.create", json!({})),
|
||||
("object.makePixelPerfect", json!({})),
|
||||
] {
|
||||
let (mut s, id) = doc_with_rect();
|
||||
let mut p = params;
|
||||
p["ids"] = json!([id]);
|
||||
let r = s.execute(cmd, &p);
|
||||
assert!(r.is_ok(), "{cmd}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_is_put_back_and_the_check_still_applies() {
|
||||
let (mut s, id) = doc_with_rect();
|
||||
// Disabled even with the object given: Make with Top Object needs two.
|
||||
let r = s.execute("object.envelope.makeWithTopObject", &json!({"ids": [id]}));
|
||||
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty(), "the stand-in selection is put back");
|
||||
// Without ids, nothing selected is still nothing selected.
|
||||
let r = s.execute("object.repeat.radial", &json!({}));
|
||||
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_ids_key_counts() {
|
||||
let p = json!({"ids": [3]});
|
||||
assert_eq!(given_ids("{ids?: [id]}", &p), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{id?|ids?, name}", &p), Some(vec![NodeId(3)]));
|
||||
assert_eq!(given_ids("{grids: n, guides: bool}", &p), None);
|
||||
// A word in what the description says about another key isn't the key.
|
||||
assert_eq!(given_ids("{name: of the swatch the ids of the selection take}", &p), None);
|
||||
assert_eq!(given_ids("{ids?: [id]}", &json!({"ids": []})), None);
|
||||
}
|
||||
|
||||
/// Every command that names `ids` and is refused with nothing selected: given the object as `ids`
|
||||
/// it does what it does with the object selected (the same result or the same error, and the same
|
||||
/// document), so no command is let through to run on an empty selection.
|
||||
#[test]
|
||||
fn ids_do_what_the_selection_does_in_every_command_that_takes_them() {
|
||||
let mut checked = vec![];
|
||||
for spec in command_specs() {
|
||||
let (mut given, id) = doc_with_rect();
|
||||
if !cmd::params::takes_key(spec.params, "ids") || (spec.enabled)(&given).is_ok() {
|
||||
continue;
|
||||
}
|
||||
let (mut selected, _) = doc_with_rect();
|
||||
selected.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let (a, b) = (given.execute(spec.id, &json!({"ids": [id]})), selected.execute(spec.id, &json!({})));
|
||||
match (&a, &b) {
|
||||
(Ok(_), Ok(_)) => {}
|
||||
(Err(x), Err(y)) => assert_eq!(x.to_string(), y.to_string(), "{}", spec.id),
|
||||
_ => panic!("{}: with ids {a:?}, with the selection {b:?}", spec.id),
|
||||
}
|
||||
match (given.doc(), selected.doc()) {
|
||||
(Ok(g), Ok(s)) => assert!(g.doc == s.doc, "{}: the documents differ", spec.id),
|
||||
(g, s) => assert_eq!(g.is_ok(), s.is_ok(), "{}", spec.id),
|
||||
}
|
||||
checked.push((spec.id, a.is_ok()));
|
||||
}
|
||||
assert!(checked.iter().filter(|c| c.1).count() >= 20, "{checked:?}");
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Appearance, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, Rect, shapes};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -22,13 +22,13 @@ fn square_at(k: usize) -> Rect {
|
||||
}
|
||||
|
||||
/// Square `k` ([`square_at`]) as a path with a new id.
|
||||
fn square(d: &mut Document, k: usize) -> Node {
|
||||
pub(crate) fn square(d: &mut Document, k: usize) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default_art())
|
||||
}
|
||||
|
||||
/// A session whose document holds `make(d, k)` for each `k` below `n` on its one layer, all of it
|
||||
/// selected.
|
||||
fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
|
||||
pub(crate) fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
|
||||
let mut d = Document::new(ROW as f64 * 20.0, (n / ROW + 1) as f64 * 20.0);
|
||||
let art: Vec<Arc<Node>> = (0..n).map(|k| Arc::new(make(&mut d, k))).collect();
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
|
||||
@@ -46,7 +46,7 @@ fn ids_of(n: &Node) -> Vec<NodeId> {
|
||||
}
|
||||
|
||||
/// The ids of the objects on layer `i`.
|
||||
fn layer(s: &Session, i: usize) -> Vec<NodeId> {
|
||||
pub(crate) fn layer(s: &Session, i: usize) -> Vec<NodeId> {
|
||||
ids_of(&s.doc().unwrap().doc.layers[i])
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ fn soon<T: Send + 'static>(mut s: Session, what: &'static str, f: impl FnOnce(&m
|
||||
}
|
||||
|
||||
/// Runs command `cmd` with `params` through [`soon`] and returns its result.
|
||||
fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
|
||||
pub(crate) fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
|
||||
let (s, r) = soon(s, cmd, move |s| s.execute(cmd, ¶ms));
|
||||
(s, r.unwrap())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ fn ids_in(r: &Value) -> Vec<NodeId> {
|
||||
}
|
||||
|
||||
/// Runs Undo and checks that it gives back `before`.
|
||||
fn undo_gives_back(s: &mut Session, before: &Document) {
|
||||
pub(crate) fn undo_gives_back(s: &mut Session, before: &Document) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
@@ -112,6 +112,28 @@ fn delete_of_100000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_selection_of_100000_rows_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "layer.delete", json!({"ids": ids}));
|
||||
assert_eq!(r["deleted"], json!(100_000));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_up_of_100000_unpainted_paths_finishes_within_20_s() {
|
||||
let s = session(100_000, |d, k| Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default()));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "object.path.cleanUp", json!({}));
|
||||
assert_eq!(r["removed"], json!(100_000));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
@@ -184,6 +206,21 @@ fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_path_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.path.offsetPath", json!({"offset": 1}));
|
||||
// Each offset path goes directly above its square and takes the next id, in the squares' order.
|
||||
let offsets = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == offsets);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &offsets), "each offset path directly above its square");
|
||||
assert!(selected(&s, &offsets));
|
||||
let grown = bounds(&s, offsets[1]);
|
||||
assert!((grown.width() - 12.0).abs() < 1e-6 && (grown.x0 - 19.0).abs() < 1e-6, "{grown:?}");
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
@@ -211,6 +248,34 @@ fn send_to_current_layer_of_100000_objects_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_drag_of_100000_rows_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let ids = layer(&s, 0);
|
||||
let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "layer.move", json!({"ids": ids, "target": to}));
|
||||
assert!(ids_in(&r) == ids);
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(children(&s, to) == ids, "the layer holds the rows in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_selected_of_100000_objects_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let (from, ids) = (s.doc().unwrap().doc.layers[0].id, layer(&s, 0));
|
||||
let into = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "layer.merge", json!({"ids": [from, into]}));
|
||||
assert_eq!(r["id"], json!(into));
|
||||
assert!(s.doc().unwrap().doc.node(from).is_none(), "the emptied layer goes");
|
||||
assert!(children(&s, into) == ids, "the layer holds the objects in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungroup_of_25000_groups_finishes_within_20_s() {
|
||||
// Each group holds two squares, and ungrouping one adds an object to the layer.
|
||||
@@ -240,6 +305,45 @@ fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() {
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_to_layers_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let (before, l, ids, first) = (doc.clone(), doc.layers[0].id, layer(&s, 0), doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "layer.releaseToLayers", json!({"id": l}));
|
||||
// Each object goes into a sublayer of its own, in their order. The sublayers take the next ids,
|
||||
// and with Layer 1 counted, sublayer k is named `Layer k + 2` and takes layer color k + 1,
|
||||
// wrapping around after the last preset.
|
||||
let subs = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == subs);
|
||||
assert!(layer(&s, 0) == subs);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
for (k, (sub, id)) in subs.iter().zip(&ids).enumerate() {
|
||||
let n = doc.node(*sub).unwrap();
|
||||
assert_eq!(ids_of(n), [*id]);
|
||||
assert_eq!(n.name.as_deref(), Some(format!("Layer {}", k + 2).as_str()));
|
||||
assert!(matches!(n.kind, NodeKind::Layer { color: LayerColor::Preset(c), .. } if usize::from(c) == (k + 1) % LAYER_COLORS.len()));
|
||||
}
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_25000_objects_finishes_within_20_s() {
|
||||
let s = session(25_000, square);
|
||||
let (before, first) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.path.outlineStroke", json!({}));
|
||||
// Each square, filled and stroked, becomes a group of its fill and the outline of its stroke,
|
||||
// where the square was, and the groups take new ids in the squares' order.
|
||||
let groups = ids_in(&r);
|
||||
assert_eq!(groups.len(), 25_000);
|
||||
assert!(layer(&s, 0) == groups, "each group where its square was");
|
||||
assert!(groups[0].0 >= first && groups.windows(2).all(|w| w[0] < w[1]));
|
||||
assert!(groups.iter().all(|g| children(&s, *g).len() == 2));
|
||||
assert!(selected(&s, &groups));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// With Alt held, a Selection tool press on one of 30,000 selected squares, four moves of 20 pt
|
||||
/// and the release copy the squares once, as one undo step.
|
||||
#[test]
|
||||
@@ -339,6 +443,193 @@ fn group_delete_and_duplicate_across_layers_and_inside_a_group_keep_order_and_id
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// After the Layers panel's Delete Selection, Duplicate Selection, row drags, Collect in New Layer
|
||||
/// and Merge Selected on rows in two layers and inside a group, the layers hold the rows and the
|
||||
/// new ones in the order, and with the ids, that each check lists, and Undo gives back the
|
||||
/// document.
|
||||
#[test]
|
||||
fn layers_panel_rows_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (n, l1, l2) = (before.peek_next_id(), before.layers[0].id.0, before.layers[1].id.0);
|
||||
|
||||
let r = s.execute("layer.delete", &json!({"ids": [a, e, h]})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([g])]);
|
||||
assert!(selected(&s, &[NodeId(c), NodeId(g)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Each copy goes directly above its row and takes the next id; a layer's copy is renamed.
|
||||
let r = s.execute("layer.duplicate", &json!({"ids": [a, e, h]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, n, b, c, [group, d, e, n + 1, f]]), json!([g, h, n + 2])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
let r = s.execute("layer.duplicate", &json!({"ids": [l2]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, h]), json!([n + 1, n + 2])]);
|
||||
assert_eq!(s.doc().unwrap().doc.layers[2].name.as_deref(), Some("Layer 2 copy"));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Rows are dragged below c, then above d in the group, and copies of rows are dragged above g.
|
||||
let r = s.execute("layer.move", &json!({"ids": [a, e, h], "target": c, "place": "below"})).unwrap();
|
||||
assert_eq!(r["ids"], json!([a, e, h]));
|
||||
assert_eq!(layers(&s), [json!([b, a, e, h, c, [group, d, f]]), json!([g])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
s.execute("layer.move", &json!({"ids": [a, g], "target": d, "place": "above"})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, a, g, e, f]]), json!([h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
let r = s.execute("layer.move", &json!({"ids": [a, e], "target": g, "place": "above", "copy": true})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, n, n + 1, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// The new layer goes where the topmost row was when that row is in a layer, and on top of the
|
||||
// layer holding the topmost row's group when it is in a group.
|
||||
assert_eq!(s.execute("layer.collectInNew", &json!({"ids": [a, e, g]})).unwrap()["id"], json!(n));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([[n, a, e, g], h])]);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(n)).unwrap().name.as_deref(), Some("Layer 3"));
|
||||
undo_gives_back(&mut s, &before);
|
||||
assert_eq!(s.execute("layer.collectInNew", &json!({"ids": [a, e]})).unwrap()["id"], json!(n));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f], [n, a, e]]), json!([g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Rows merge into the layer listed last, in the stacking order, and so does a merged layer's
|
||||
// art.
|
||||
s.execute("layer.merge", &json!({"ids": [e, g, l1]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, f], e, g]), json!([h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
s.execute("layer.merge", &json!({"ids": [l1, l2]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f], g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// Release to Layers (Sequence on a layer and on a group, Build on a layer) and Flatten Artwork
|
||||
/// leave the layers, sublayers and objects in the order, and with the ids and names, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn release_to_layers_and_flatten_artwork_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let l2 = s.doc().unwrap().doc.layers[1].id.0;
|
||||
// Layer 2 also holds a sublayer, on top of g and h.
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": l2})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (n, l1) = (before.peek_next_id(), before.layers[0].id.0);
|
||||
let names = |s: &Session, r: &Value| ids_in(r).iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().display_name()).collect::<Vec<_>>();
|
||||
|
||||
// A layer's objects go into sublayers of their own, below its sublayers.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": l2})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, g], [n + 1, h], [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Build: a sublayer holds copies of the objects below its own and a copy of its own, and the
|
||||
// top one holds the objects.
|
||||
let r = s.execute("layer.releaseToLayersBuild", &json!({"id": l2})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, n + 1], [n + 2, g, h], [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// A group's objects go into sublayers of its layer, where the group was.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": group})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2]));
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [n, d], [n + 1, e], [n + 2, f]]), json!([g, h, [sub]])]);
|
||||
assert_eq!(names(&s, &r), ["Layer 4", "Layer 5", "Layer 6"]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Flatten Artwork into Layer 1 deletes the two hidden layers and keeps the template layer.
|
||||
for p in [json!({"top": true, "visible": false}), json!({"top": true, "visible": false}), json!({"top": true, "template": true})] {
|
||||
s.execute("layer.new", &p).unwrap();
|
||||
}
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(s.execute("layer.flatten", &json!({"id": l1})).unwrap()["discarded"], 2);
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f], g, h, [sub]]), json!([])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// Duplicate Artboard and `artboard.move` with `copy` and `moveArt` (the Artboard tool's Alt-drag
|
||||
/// with Move/Copy Artwork with Artboard on) put a copy of each object on the artboard directly
|
||||
/// above it, moved with the artboard's copy, and Cut Artboard removes those objects. An object off
|
||||
/// the artboard stays. The layers then hold the objects in the order, and with the ids, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn artboard_copies_and_cut_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let off = s.execute("shape.rectangle", &json!({"x": 900, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
let copies = json!([n, n + 1, n + 2, n + 3, n + 7, n + 8]);
|
||||
let copied = [json!([a, n, b, n + 1, c, n + 2, [group, d, e, f], [n + 3, n + 4, n + 5, n + 6]]), json!([g, n + 7, h, n + 8, off])];
|
||||
// The copy of h, the last object on the artboard, sits where h sits, moved by `dx`.
|
||||
let moved_by = |s: &Session, dx: f64| bounds(s, NodeId(n + 8)) == bounds(s, NodeId(h)) + vectorcraft_geom::Vec2::new(dx, 0.0);
|
||||
|
||||
// Duplicate Artboard places the copy 20 pt right of the last artboard.
|
||||
let right = before.artboards.iter().map(|ab| ab.rect.x1).fold(f64::MIN, f64::max);
|
||||
assert_eq!(s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"], copies);
|
||||
assert_eq!(layers(&s), copied);
|
||||
assert!(moved_by(&s, right + 20.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
let r = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap();
|
||||
assert_eq!(r["moved"], copies);
|
||||
assert_eq!(layers(&s), copied);
|
||||
assert!(moved_by(&s, 900.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
assert_eq!(s.execute("artboard.cut", &json!({"index": 0, "art": true})).unwrap()["copied"], 6);
|
||||
assert_eq!(layers(&s), [json!([]), json!([off])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// After Offset Path, Outline Stroke, Unite and Clean Up of objects in two layers and inside a
|
||||
/// group, the layers hold the objects and the new ones in the order, and with the ids, that each
|
||||
/// check lists, and Undo gives back the document.
|
||||
#[test]
|
||||
fn path_commands_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
|
||||
let r = s.execute("object.path.offsetPath", &json!({"offset": 1})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1, n + 2, n + 3, n + 4]));
|
||||
assert_eq!(layers(&s), [json!([a, n, b, c, n + 1, [group, d, e, n + 2, f]]), json!([g, n + 3, h, n + 4])]);
|
||||
assert!(selected(&s, &ids_from(n, 5)));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// Each object becomes a group of its fill and the outline of its stroke, where it was.
|
||||
let r = s.execute("object.path.outlineStroke", &json!({})).unwrap();
|
||||
let [a2, c2, e2, g2, h2] = <[NodeId; 5]>::try_from(ids_in(&r)).unwrap();
|
||||
assert!(n <= a2.0 && a2 < c2 && c2 < e2 && e2 < g2 && g2 < h2);
|
||||
assert_eq!(layer(&s, 0), [a2, NodeId(b), c2, NodeId(group)]);
|
||||
assert_eq!(children(&s, NodeId(group)), [NodeId(d), e2, NodeId(f)]);
|
||||
assert_eq!(layer(&s, 1), [g2, h2]);
|
||||
assert!(selected(&s, &[a2, c2, e2, g2, h2]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
// The result goes where the topmost object, h, was.
|
||||
let r = s.execute("object.pathfinder.unite", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n]));
|
||||
assert_eq!(layers(&s)[0], json!([b, [group, d, f]]));
|
||||
assert_eq!(layer(&s, 1), [NodeId(n)]);
|
||||
assert!(selected(&s, &[NodeId(n)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
s.edit("Paint nothing", |d, _| {
|
||||
for id in [a, e, h] {
|
||||
d.node_mut(NodeId(id)).unwrap().appearance = Appearance::default().into();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let r = s.execute("object.path.cleanUp", &json!({})).unwrap();
|
||||
assert_eq!(r["removed"], json!(3));
|
||||
assert_eq!(layers(&s), [json!([b, c, [group, d, f]]), json!([g])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// `object.transform` copies ids listed twice, and an object listed before a group around it.
|
||||
/// Each copy goes directly above its original, a later copy of the same object goes below an
|
||||
/// earlier one, and the copy of the group holds a copy of the copy made inside the group before it.
|
||||
|
||||
@@ -17,6 +17,38 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
|
||||
/// placing and listing read).
|
||||
fn png_header(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
|
||||
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
|
||||
out.extend((chunk.len() as u32 - 4).to_be_bytes());
|
||||
out.extend(chunk);
|
||||
out.extend(crc32fast::hash(chunk).to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An image past what a decode may take isn't drawn: Place and Open say so and the Links panel's
|
||||
/// row carries the note, where the image used to be blank with no word (#1479).
|
||||
#[test]
|
||||
fn an_image_too_large_to_draw_is_reported() {
|
||||
const NOTE: &str = "an image of 200000 × 200000 pixels is too large to draw";
|
||||
let dir = Folder::new("huge");
|
||||
let (big, small) = (dir.file("wall.png"), dir.file("small.png"));
|
||||
write(&big, &png_header(200_000, 200_000));
|
||||
write(&small, &png(40, 20, BLUE));
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": big, "at": [200, 150], "link": false}))["warnings"], json!([NOTE]));
|
||||
assert_eq!(run(&mut s, "file.place", json!({"path": small, "at": [20, 20], "link": false}))["warnings"], json!([]));
|
||||
let l = run(&mut s, "links.list", json!({}));
|
||||
let notes: Vec<&Value> = l["links"].as_array().unwrap().iter().map(|r| &r["note"]).collect();
|
||||
assert_eq!(notes, [&Value::Null, &json!(NOTE)], "top first: the small image has nothing to say");
|
||||
// Opened as a document: said too. An image that decodes (16,000 px square) has no note.
|
||||
assert_eq!(crate::cmd::fileio::load("wall.png", &png_header(200_000, 200_000)).unwrap().warnings, [NOTE]);
|
||||
assert!(crate::cmd::fileio::load("fine.png", &png_header(16_000, 16_000)).unwrap().warnings.is_empty());
|
||||
}
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
@@ -217,6 +217,35 @@ fn blend_release_restores_keys() {
|
||||
assert!(s.execute("object.blend.release", &json!({})).is_ok() || s.execute("object.blend.release", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Release Blend of a blend and of a blend inside a group that is a key object of the first puts
|
||||
/// the outer blend's spine and keys where it was, then the inner blend's spine and keys where it
|
||||
/// was inside that group, as releasing one blend at a time does.
|
||||
#[test]
|
||||
fn blend_release_of_a_blend_and_a_blend_inside_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (r1, r2) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||||
sel(&mut s, &[r1, r2]);
|
||||
let inner = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
let r3 = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[inner, r3]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let r4 = rect(&mut s, 400.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[group, r4]);
|
||||
let outer = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
sel(&mut s, &[outer, inner]);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
let r = s.execute("object.blend.release", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"], json!([group.0, r4.0, r1.0, r2.0]));
|
||||
assert_eq!(r["spines"], json!([n, n + 1]));
|
||||
let ids = |n: &Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&s.doc().unwrap().doc.layers[0]), [NodeId(n), group, r4]);
|
||||
assert_eq!(ids(&node(&s, group)), [NodeId(n + 1), r1, r2, r3]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(n), NodeId(n + 1), group, r4, r1, r2]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_expand_makes_group_with_unique_ids() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_doc::{Appearance, ColorMode, LiveShape, Node, NodeKind, TextKind};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::tests_large_edits::{layer, run_soon, square, undo_gives_back};
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
@@ -137,6 +138,100 @@ fn hide_all_artwork_above_deselects_the_objects_it_hides() {
|
||||
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
|
||||
}
|
||||
|
||||
/// Lock › All Artwork Above and Hide › All Artwork Above take each object that overlaps a selected
|
||||
/// object below it in the same layer, once, whether it is locked or hidden already or not. An object
|
||||
/// that overlaps only a selected object above it, the group around a selected object, the selected
|
||||
/// objects and an object that lies over a selected object of another layer stay as they are.
|
||||
#[test]
|
||||
fn lock_and_hide_all_artwork_above_take_what_overlaps_a_selected_object_below_in_its_layer() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
// `b` overlaps only `c`, which lies above it.
|
||||
let b = rect(&mut s, 250.0, 0.0, 100.0, 100.0);
|
||||
let c = rect(&mut s, 90.0, 0.0, 200.0, 100.0);
|
||||
// `d` overlaps `a`, `e` overlaps `c`, `f` overlaps both, `g` overlaps neither, and `k` overlaps `a` and is locked.
|
||||
let d = rect(&mut s, 20.0, 50.0, 30.0, 30.0);
|
||||
let e = rect(&mut s, 200.0, 50.0, 30.0, 30.0);
|
||||
let f = rect(&mut s, 60.0, 10.0, 50.0, 20.0);
|
||||
let g = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
|
||||
let k = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap();
|
||||
// `m` is in a group, and `n` overlaps it.
|
||||
let m = rect(&mut s, 800.0, 0.0, 50.0, 50.0);
|
||||
sel(&mut s, &[m]);
|
||||
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let n = rect(&mut s, 820.0, 20.0, 50.0, 50.0);
|
||||
// In another layer, `h` lies where `a` lies, and `j` overlaps `i`.
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let h = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let i = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
|
||||
let j = rect(&mut s, 650.0, 50.0, 100.0, 100.0);
|
||||
let (taken, kept) = ([d, e, f, k, n, j], [a, b, c, g, group, m, h, i]);
|
||||
for (cmd, lock) in [("object.lock.above", true), ("object.hide.above", false)] {
|
||||
let done = |s: &Session, id: NodeId| if lock { node(s, id).locked } else { !node(s, id).visible };
|
||||
sel(&mut s, &[a, c, m, i]);
|
||||
let steps = undo_len(&s);
|
||||
assert_eq!(s.execute(cmd, &json!({})).unwrap(), json!({"count": 6}), "{cmd}");
|
||||
assert!(taken.iter().all(|id| done(&s, *id)), "{cmd}");
|
||||
assert!(!kept.iter().any(|id| done(&s, *id)), "{cmd}");
|
||||
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, c, m, i]), "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A session with 1,000 tiles of 100 x 200 pt in rows of 40 under the 50,000 squares of [`square`],
|
||||
/// 50 on each tile, all of it selected; and the ids of the tiles and of the squares.
|
||||
fn tiles_under_squares() -> (Session, Vec<NodeId>, Vec<NodeId>) {
|
||||
let tile = |t: usize| {
|
||||
let (x, y) = ((t % 40) as f64 * 100.0, (t / 40) as f64 * 200.0);
|
||||
Rect::new(x, y, x + 100.0, y + 200.0)
|
||||
};
|
||||
let s = super::tests_large_edits::session(51_000, |d, k| {
|
||||
if k < 1000 { Node::path(d.alloc_id(), shapes::rectangle(tile(k)), Appearance::default_art()) } else { square(d, k - 1000) }
|
||||
});
|
||||
let mut tiles = layer(&s, 0);
|
||||
let squares = tiles.split_off(1000);
|
||||
(s, tiles, squares)
|
||||
}
|
||||
|
||||
/// With 1,000 tiles selected under 50,000 squares, Lock › All Artwork Above locks every square in one
|
||||
/// undo step and Hide › All Artwork Above hides every square, each within 20 s, and the tiles stay
|
||||
/// selected.
|
||||
#[test]
|
||||
fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_20_s() {
|
||||
let (mut s, tiles, squares) = tiles_under_squares();
|
||||
sel(&mut s, &tiles);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, r) = run_soon(s, "object.lock.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 50_000}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| n.locked)), "every square is locked");
|
||||
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| !n.locked)), "no tile is locked");
|
||||
assert!(selected(&s) == tiles, "the tiles stay selected");
|
||||
undo_gives_back(&mut s, &before);
|
||||
let (s, r) = run_soon(s, "object.hide.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 50_000}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| !n.visible)), "every square is hidden");
|
||||
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| n.visible)), "no tile is hidden");
|
||||
assert!(selected(&s) == tiles, "the tiles stay selected");
|
||||
}
|
||||
|
||||
/// With all 51,000 objects of [`tiles_under_squares`] selected, Lock › All Artwork Above and Hide ›
|
||||
/// All Artwork Above each find nothing to lock or hide within 20 s and record no undo step.
|
||||
#[test]
|
||||
fn lock_and_hide_all_artwork_above_with_51000_objects_selected_finish_within_20_s() {
|
||||
let (s, tiles, squares) = tiles_under_squares();
|
||||
let (steps, all) = (undo_len(&s), [tiles, squares].concat());
|
||||
assert!(selected(&s) == all, "everything is selected");
|
||||
let (s, r) = run_soon(s, "object.lock.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 0}));
|
||||
let (s, r) = run_soon(s, "object.hide.above", json!({}));
|
||||
assert_eq!(r, json!({"count": 0}));
|
||||
assert_eq!(undo_len(&s), steps);
|
||||
assert!(selected(&s) == all, "everything stays selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_hide_other_layers() {
|
||||
let mut s = session();
|
||||
@@ -1249,6 +1344,26 @@ fn ruler_guides_and_lock() {
|
||||
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
/// #1534: Lock Guides locks the paths made into guides too: they can't be selected (so neither
|
||||
/// moved, reshaped nor deleted), by a click, a marquee or Select All, and locking lets them go.
|
||||
#[test]
|
||||
fn locked_guides_lock_guide_paths() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 20.0, 10.0, 10.0));
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
assert_eq!(selected(&s), vec![a, b]);
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
}
|
||||
|
||||
fn guides(s: &Session) -> Vec<(bool, f64)> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
|
||||
}
|
||||
@@ -1826,3 +1941,15 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64()), (Some(false), Some(30)), "{r}");
|
||||
assert_eq!(bounds(&s, img), b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_image_trim_leaves_a_cmyk_image_alone() {
|
||||
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [255, 0, 0, 0].repeat(8)).unwrap();
|
||||
let tiff = vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "inks.tif", "dataBase64": vectorcraft_format::base64_encode(&tiff.bytes)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let r = s.execute("object.cropImage", &json!({"trim": true})).unwrap();
|
||||
assert_eq!((r["trimmed"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), (Some(false), Some(4), Some(2)), "{r}");
|
||||
}
|
||||
@@ -152,6 +152,30 @@ fn release_while_editing_leaves_editing_first() {
|
||||
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
|
||||
}
|
||||
|
||||
/// Releasing the masks of a group and of an object inside it puts each mask's art directly above
|
||||
/// its object, as releasing one mask at a time does.
|
||||
#[test]
|
||||
fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids() {
|
||||
let mut s = session();
|
||||
let (a, m1) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 0.0, 50.0));
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m1.0]})).unwrap();
|
||||
let b = rect(&mut s, 150.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let m2 = rect(&mut s, 0.0, 300.0);
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [group.0, m2.0]})).unwrap();
|
||||
let before = doc(&s).clone();
|
||||
let n = before.peek_next_id();
|
||||
s.execute("transparency.releaseOpacityMask", &json!({"ids": [group.0, a.0]})).unwrap();
|
||||
let ids = |n: &vectorcraft_doc::Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
assert_eq!(ids(&doc(&s).layers[0]), [group, NodeId(n)]);
|
||||
assert_eq!(ids(doc(&s).node(group).unwrap()), [a, NodeId(n + 1), b]);
|
||||
assert!(mask(&s, group).is_none() && mask(&s, a).is_none());
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [group, NodeId(n), a, NodeId(n + 1)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*doc(&s) == before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::params::{UNCHECKED, check_keys, check_params, doc_keys};
|
||||
use crate::cmd::params::{UNCHECKED, check_effect_params, check_keys, check_params, doc_keys};
|
||||
use crate::{EngineError, Session, command_specs, find_command};
|
||||
|
||||
fn keys(doc: &str) -> Vec<&str> {
|
||||
@@ -95,6 +95,50 @@ fn a_batch_checks_its_steps() {
|
||||
assert_eq!(refusal("command.batch", json!({"steps": []})), "unknown parameter `steps`; the command takes: label, commands, artboard");
|
||||
}
|
||||
|
||||
/// #1592: the `params` of an effect are held to the effect's own keys and types, where the effect
|
||||
/// is named (`effect.apply`) and where the document says which it is (`effect.setParams`).
|
||||
#[test]
|
||||
fn effect_params_are_the_effects_own() {
|
||||
use vectorcraft_render::effects::effect_catalog;
|
||||
for e in effect_catalog() {
|
||||
doc_keys(e.params).unwrap_or_else(|why| panic!("{}: {why}", e.id));
|
||||
check_effect_params("effect.apply", e.id, &e.defaults).unwrap_or_else(|why| panic!("{}'s defaults: {why}", e.id));
|
||||
}
|
||||
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
|
||||
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"blurr": 3}))), "unknown parameter `blurr`; the command takes: radius");
|
||||
assert_eq!(
|
||||
refusal("appearance.addEffect", apply("stylize.dropShadow", json!({"opacty": 50}))),
|
||||
"unknown parameter `opacty`; did you mean `opacity`?"
|
||||
);
|
||||
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"radius": "big"}))), "`radius` must be a number");
|
||||
assert_eq!(refusal("effect.apply", apply("threeD.revolve", json!({"shade": 1}))), "`shade` must be true or false");
|
||||
assert_eq!(refusal("effect.apply", apply("threeD.revolve", json!({"edge": 1}))), "`edge` must be a string");
|
||||
// Out of range is the effect's to clamp; a key only its description names is one it takes;
|
||||
// a plug-in's are its manifest's; an unknown effect is the command's to report.
|
||||
for (effect, params) in [
|
||||
("stylize.dropShadow", json!({"opacity": 500, "x": -3, "color": "#ff0000"})),
|
||||
("stylize.dropShadow", json!({"color": [1, 0, 0]})),
|
||||
("threeD.revolve", json!({"segments": 100000})),
|
||||
("path.outlineStroke", json!({"width": 2})),
|
||||
("plugin.anything", json!({"whatever": 1})),
|
||||
("no.such", json!({"whatever": 1})),
|
||||
] {
|
||||
assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}");
|
||||
}
|
||||
// `effect.setParams` names an index: the session knows the effect there.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
s.execute("effect.apply", &apply("blur.gaussian", json!({"radius": 2}))).unwrap();
|
||||
for (params, why) in [(json!({"blurr": 3}), "unknown parameter `blurr`"), (json!({"radius": true}), "`radius` must be a number")] {
|
||||
let e = s.execute("effect.setParams", &json!({"index": 0, "params": params})).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{e}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"radius": 1e9}, "visible": false})).unwrap();
|
||||
// No effect at the index: the command says so, not the check.
|
||||
assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok());
|
||||
}
|
||||
|
||||
/// What a command notes for its journal entry is a key its description names, so the entry
|
||||
/// passes the check a caller's params get.
|
||||
#[test]
|
||||
|
||||
@@ -893,3 +893,40 @@ fn divide_keeps_implicitly_closed_filled_paths_including_zero_signed_area() {
|
||||
assert!(pieces.iter().all(|n| n.appearance == source.appearance));
|
||||
}
|
||||
}
|
||||
|
||||
/// #1570: Trim, Merge and Crop remove the objects' strokes; Merge then merges same-coloured
|
||||
/// fills whatever their strokes were.
|
||||
#[test]
|
||||
fn trim_merge_and_crop_remove_strokes() {
|
||||
for op in ["trim", "merge", "crop"] {
|
||||
let mut s = session();
|
||||
let (a, b) = two_rects(&mut s);
|
||||
for (id, c) in [(a, Color::rgb(0.0, 0.0, 0.0)), (b, Color::rgb(1.0, 0.0, 0.0))] {
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(id).unwrap().appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(c), 3.0).into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let r = ids(&s.execute(&format!("object.pathfinder.{op}"), &json!({})).unwrap());
|
||||
let g = node(&s, r[0]);
|
||||
let pieces = g.children().unwrap();
|
||||
assert!(!pieces.is_empty(), "{op}");
|
||||
assert!(pieces.iter().all(|c| c.appearance.stroke_width() == 0.0 && c.appearance.fill().is_some()), "{op} keeps a stroke");
|
||||
if op == "merge" {
|
||||
assert_eq!(pieces.len(), 1, "same fill, different strokes: merged");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1590: an inset larger than the shape leaves nothing, and the error says so rather than asking
|
||||
/// for paths that are selected.
|
||||
#[test]
|
||||
fn offset_path_that_leaves_nothing_says_so() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let e = s.execute("object.path.offsetPath", &json!({"offset": -20})).unwrap_err().to_string();
|
||||
assert!(e.contains("leaves nothing") && !e.contains("select paths"), "{e}");
|
||||
}
|
||||
@@ -106,10 +106,19 @@ fn listed_applied_edited_and_undone() {
|
||||
assert_eq!((&e["raster"], &e["menu"]), (&json!(true), &json!(["Effect", menu])), "{id}");
|
||||
}
|
||||
}
|
||||
// Junk parameters are stored as given and read clamped; the canvas still draws.
|
||||
// Numbers far out of range are stored as given and read clamped; the canvas still draws.
|
||||
for (id, _) in EFFECTS {
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
|
||||
.unwrap();
|
||||
let defaults = vectorcraft_render::effects::default_params(id).unwrap();
|
||||
let junk: serde_json::Map<String, Value> = defaults
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, v)| v.is_number())
|
||||
.enumerate()
|
||||
.map(|(i, (k, _))| (k.clone(), json!(if i % 2 == 0 { 1e308 } else { -1e308 })))
|
||||
.collect();
|
||||
assert!(!junk.is_empty(), "{id}");
|
||||
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": junk})).unwrap();
|
||||
}
|
||||
let _ = render(&s);
|
||||
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! `script.check`: scripts written for the reference app checked without running them, from a path,
|
||||
//! text or base64; the files their `#include` lines name read from the script's folder when asked,
|
||||
//! through the automation read root; and the limits.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn check(p: Value) -> Value {
|
||||
Session::new().execute("script.check", &p).unwrap_or_else(|e| panic!("script.check {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A fresh folder for the files of test `name`.
|
||||
fn folder(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vc-script-check-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("scripts/lib")).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn statuses(r: &Value) -> Vec<&str> {
|
||||
r["includes"].as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_is_reported_without_running_it() {
|
||||
let src = concat!(
|
||||
"#target acme-12\n",
|
||||
"var doc = app.activeDocument;\n",
|
||||
"app.executeMenuCommand('acmecopyall');\n",
|
||||
"var f = new File(p); f.open('w');\n",
|
||||
"alert('done');\n",
|
||||
);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("script.check", &json!({"data": src, "name": "/Users/me/Tidy Layers.jsx"})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["kind"].as_str(), r["lines"].as_u64()), (Some("Tidy Layers.jsx"), Some("script"), Some(5)));
|
||||
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands"]));
|
||||
assert_eq!(r["targets"], json!([{"value": "acme-12", "app": "acme", "version": "12", "line": 1}]));
|
||||
assert_eq!(r["directives"], json!([{"name": "target", "value": "acme-12", "line": 1, "form": "#"}]));
|
||||
assert_eq!(r["menuCommands"], json!({"strings": [{"value": "acmecopyall", "count": 1, "lines": [3]}], "dynamic": 0}));
|
||||
assert_eq!(r["outside"], json!([{"id": "filesWritten", "count": 1, "lines": [4], "modes": ["w"]}]));
|
||||
assert_eq!(r["dom"], json!({"members": [{"path": "app.activeDocument", "count": 1}], "types": []}));
|
||||
let notify = r["features"].as_array().unwrap().iter().find(|f| f["id"] == "notify").unwrap();
|
||||
assert_eq!(notify, &json!({"id": "notify", "count": 1, "lines": [5]}));
|
||||
assert_eq!((r["problems"].clone(), r["includes"].clone(), r["truncated"].clone()), (json!([]), json!([]), json!(false)));
|
||||
// Nothing ran: the document has no new undo step, and the check isn't recorded.
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert!(!find_command("script.check").unwrap().journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_text_and_base64_give_one_report() {
|
||||
let dir = folder("forms");
|
||||
let src = "#include 'lib/x.jsxinc'\nvar s = 'open;\n";
|
||||
let path = dir.join("scripts/Tidy.jsx");
|
||||
std::fs::write(&path, src).unwrap();
|
||||
let by_path = check(json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(by_path["name"], "Tidy.jsx");
|
||||
assert_eq!(by_path["problems"], json!([{"code": "unterminatedString", "line": 2}]));
|
||||
assert_eq!(check(json!({"data": src, "name": "Tidy.jsx"})), by_path);
|
||||
assert_eq!(check(json!({"dataBase64": vectorcraft_format::base64_encode(src.as_bytes()), "name": "Tidy.jsx"})), by_path);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_are_read_from_the_scripts_folder_only_when_asked() {
|
||||
let dir = folder("includes");
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/net.jsxinc'\n#include '../outside.jsxinc'\n#include 'lib/none.jsxinc'\n").unwrap();
|
||||
std::fs::write(dir.join("scripts/lib/net.jsxinc"), "var s = new Socket();\n").unwrap();
|
||||
std::fs::write(dir.join("outside.jsxinc"), "alert('outside');\n").unwrap();
|
||||
let path = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
let r = check(json!({"path": path}));
|
||||
assert_eq!(statuses(&r), ["notRead", "notRead", "notRead"]);
|
||||
assert_eq!(r["needs"], json!(["language"]));
|
||||
let r = check(json!({"path": path, "includes": true}));
|
||||
assert_eq!(statuses(&r), ["checked", "notFollowed", "missing"]);
|
||||
assert_eq!(r["includes"][0]["path"], "lib/net.jsxinc");
|
||||
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
|
||||
assert_eq!(r["outside"], json!([{"id": "network", "count": 1, "lines": []}]), "an included file's lines aren't the script's");
|
||||
// Text has no folder to read from.
|
||||
let r = check(json!({"data": "#include 'lib/net.jsxinc'\n", "includes": true}));
|
||||
assert_eq!(statuses(&r), ["notRead"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_given_with_a_path_names_the_reply_and_the_includes_follow_the_path() {
|
||||
let dir = folder("named");
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'helper.jsx'\n").unwrap();
|
||||
std::fs::write(dir.join("scripts/helper.jsx"), "var s = new Socket();\n").unwrap();
|
||||
let path = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
let r = check(json!({"path": path, "name": "helper.jsx", "includes": true}));
|
||||
assert_eq!(r["name"], "helper.jsx");
|
||||
assert_eq!(statuses(&r), ["checked"]);
|
||||
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_the_folder_and_the_automation_read_root_keep_reads_inside() {
|
||||
let dir = folder("root");
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/link.jsxinc'\n#include 'lib/ok.jsxinc'\n").unwrap();
|
||||
std::fs::write(dir.join("scripts/lib/ok.jsxinc"), "alert(1);\n").unwrap();
|
||||
std::fs::write(dir.join("secret.jsxinc"), "var s = new Socket();\n").unwrap();
|
||||
std::os::unix::fs::symlink(dir.join("secret.jsxinc"), dir.join("scripts/lib/link.jsxinc")).unwrap();
|
||||
std::fs::write(dir.join("other.jsx"), "alert(2);\n").unwrap();
|
||||
let main = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
// A link that leads out of the script's folder isn't followed.
|
||||
let r = check(json!({"path": main, "includes": true}));
|
||||
assert_eq!(statuses(&r), ["notFollowed", "checked"]);
|
||||
assert_eq!(r["outside"], json!([]));
|
||||
// Under a read root, the script and its includes are read inside it, and a script outside isn't.
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&dir.join("scripts")), None).unwrap().unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = crate::file_access::confine(Some(&roots), || s.execute("script.check", &json!({"path": main, "includes": true}))).unwrap();
|
||||
assert_eq!(statuses(&r), ["notFollowed", "checked"]);
|
||||
let other = dir.join("other.jsx").to_string_lossy().into_owned();
|
||||
let e = crate::file_access::confine(Some(&roots), || s.execute("script.check", &json!({"path": other}))).unwrap_err();
|
||||
assert!(e.to_string().contains("outside the read root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pipes_and_folders_are_not_read() {
|
||||
let dir = folder("pipes");
|
||||
let pipe = dir.join("scripts/lib/pipe.jsxinc");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&pipe).status().unwrap().success());
|
||||
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/pipe.jsxinc'\n#include 'lib'\n").unwrap();
|
||||
let main = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
|
||||
let pipe = pipe.to_string_lossy().into_owned();
|
||||
// Opening a pipe that has no writer waits for one: the checks run on a thread with a time limit.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut s = Session::new();
|
||||
let included = s.execute("script.check", &json!({"path": main, "includes": true})).map(|r| r["includes"].clone());
|
||||
let direct = s.execute("script.check", &json!({ "path": pipe })).map_err(|e| e.to_string());
|
||||
let _ = tx.send((included.map_err(|e| e.to_string()), direct));
|
||||
});
|
||||
let (included, direct) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("the checks return");
|
||||
let statuses: Vec<String> = included.unwrap().as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(statuses, ["missing", "missing"]);
|
||||
assert!(direct.is_err_and(|e| e.contains("not a file")));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_limits_and_bad_params() {
|
||||
let kind = |p: Value| check(p)["kind"].as_str().unwrap().to_string();
|
||||
assert_eq!(kind(json!({"data": "@JSXBIN@ES@2.0@AcmeBody", "name": "a.jsx"})), "compiled");
|
||||
assert_eq!(kind(json!({"data": "alert(1);", "name": "a.jsxbin"})), "compiled");
|
||||
assert_eq!(kind(json!({"data": "import a from 'a';\nexport default a;\n"})), "module");
|
||||
assert_eq!(kind(json!({"dataBase64": vectorcraft_format::base64_encode(b"var a;\0")})), "notText");
|
||||
// Past 8 MiB as text and as base64: only the head is decoded.
|
||||
let big = "a".repeat((8 << 20) + 1);
|
||||
assert_eq!(kind(json!({"data": big})), "tooLarge");
|
||||
assert_eq!(kind(json!({"dataBase64": vectorcraft_format::base64_encode(big.as_bytes())})), "tooLarge");
|
||||
assert_eq!(kind(json!({"data": &big[1..]})), "script");
|
||||
let mut s = Session::new();
|
||||
for (p, want) in [
|
||||
(json!({}), "give `path`, `data` or `dataBase64`"),
|
||||
(json!({"dataBase64": "@@@"}), "bad dataBase64"),
|
||||
(json!({"path": std::env::temp_dir().join("vc-no-such-script.jsx").to_string_lossy()}), "vc-no-such-script.jsx"),
|
||||
] {
|
||||
let e = s.execute("script.check", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(want), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,8 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() {
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"ss01"))), (tag(b"Valu"), F::I32(1))],
|
||||
// Character variants aren't rendered yet (stylistic sets are, #1607).
|
||||
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"cv01"))), (tag(b"Valu"), F::I32(1))],
|
||||
],
|
||||
),
|
||||
)],
|
||||
@@ -169,7 +171,7 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() {
|
||||
let t = texts(&loaded.doc)[0];
|
||||
assert_eq!(t.runs[0].text, "Mixed");
|
||||
assert!(t.runs[0].style.all_caps);
|
||||
assert_eq!(t.runs[0].style.features, ["-liga", "ss01"]);
|
||||
assert_eq!(t.runs[0].style.features, ["-liga", "ss01", "cv01"]);
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("retained but not rendered")));
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
|
||||
assert_eq!(texts(&restored)[0], t);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
|
||||
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the
|
||||
//! font files a folder search reads (damaged fonts, collections whose headers give any count of
|
||||
//! faces).
|
||||
//! faces), nor scripts written for the reference app checked without running them (garbage,
|
||||
//! generated script text and mutated scripts).
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
@@ -1290,6 +1291,13 @@ proptest! {
|
||||
survive("mutated EPS PostScript", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// A damaged EPS another app copied: Paste reads it as an error or as objects.
|
||||
#[test]
|
||||
fn pasted_eps_never_panics(cut in 0usize..400_000, edits in prop::collection::vec((0usize..400_000, any::<u8>()), 0..12)) {
|
||||
static SAMPLE: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
paste_from_elsewhere("clipboard.importEps", json!({"dataBase64": mutated_b64(SAMPLE.get_or_init(foreign_eps_sample).clone(), cut, &edits)}))?;
|
||||
}
|
||||
|
||||
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
|
||||
/// as files in the legacy Illustrator format too (the creator line is what turns on its
|
||||
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which
|
||||
@@ -2320,3 +2328,115 @@ proptest! {
|
||||
search_reads("mutated font file", &bytes)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- scripts checked without running them ----------
|
||||
|
||||
/// A script written for these tests, with invented names: directives in both forms, member chains,
|
||||
/// files, a dialog, menu commands, E4X, an operator and a message to another app.
|
||||
const SCRIPT_SAMPLE: &str = concat!(
|
||||
"#target acme_2-12-en_US\n",
|
||||
"//@include \"lib/util.jsxinc\"\n",
|
||||
"#includepath \"lib;shared\"\n",
|
||||
"#targetengine session\n",
|
||||
"var doc = app.activeDocument;\n",
|
||||
"var w = new Window(\"dialog { text: 'Tidy' }\");\n",
|
||||
"var f = File.saveDialog('Save'); f.open('w'); f.write(doc.acmeName); f.close();\n",
|
||||
"app.executeMenuCommand('acmecopyall');\n",
|
||||
"var x = <root><item id=\"1\">{doc.acmeName}</item></root>;\n",
|
||||
"for each (var i in x..item) { $.writeln(i.@id); }\n",
|
||||
"Vec.prototype['+'] = function (o) { return o; };\n",
|
||||
"var bt = new BridgeTalk(); bt.target = 'acme'; bt.send();\n",
|
||||
"if (item.typename == 'AcmeShape') { alert(/a[/]b/g.test(s) ? 'yes' : \"no\"); }\n",
|
||||
);
|
||||
|
||||
/// The pieces generated scripts are made of.
|
||||
const SCRIPT_PIECES: &[&str] = &[
|
||||
"#target acme",
|
||||
"//@include 'a.jsxinc'",
|
||||
"#includepath 'x;..'",
|
||||
"#pragma",
|
||||
"app",
|
||||
"activeDocument",
|
||||
"selection",
|
||||
"var doc = ",
|
||||
".",
|
||||
".acmeItems",
|
||||
"(",
|
||||
")",
|
||||
"[",
|
||||
"]",
|
||||
"{",
|
||||
"}",
|
||||
";",
|
||||
"=",
|
||||
"'str'",
|
||||
"\"open",
|
||||
"/re[/]x/g",
|
||||
"/",
|
||||
"<a b=\"{v}\">",
|
||||
"</a>",
|
||||
"<!--",
|
||||
".@",
|
||||
"..",
|
||||
"`t",
|
||||
"=>",
|
||||
"\n",
|
||||
"\r",
|
||||
"\u{2028}",
|
||||
"@JSXBIN@",
|
||||
"executeMenuCommand",
|
||||
"new File(p)",
|
||||
"Window(",
|
||||
"import",
|
||||
"/*",
|
||||
"\\",
|
||||
"0x1F",
|
||||
" ",
|
||||
];
|
||||
|
||||
/// `script.check` with `p`, which mustn't panic, and whose lists keep to their limit.
|
||||
fn checked(what: &str, p: Value) -> Result<(), TestCaseError> {
|
||||
let r = catch_quiet(|| no_panic(vectorcraft_engine::Session::new().execute("script.check", &p)))
|
||||
.map_err(|e| TestCaseError::fail(format!("{what}: script.check panicked: {e}")))?;
|
||||
if let Ok(r) = r {
|
||||
for list in [&r["targets"], &r["directives"], &r["includes"], &r["problems"], &r["dom"]["members"], &r["menuCommands"]["strings"]] {
|
||||
prop_assert!(list.as_array().is_some_and(|l| l.len() <= 1000), "{}: {}", what, list);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sample the properties mutate reaches every part of the check.
|
||||
#[test]
|
||||
fn the_script_sample_is_checked_whole() {
|
||||
let r = vectorcraft_engine::Session::new().execute("script.check", &json!({"data": SCRIPT_SAMPLE})).unwrap();
|
||||
assert_eq!(r["kind"], "script");
|
||||
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands", "dialogs", "hostOnly"]));
|
||||
assert_eq!(r["problems"], json!([]));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn script_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2_000), compiled in any::<bool>()) {
|
||||
let name = if compiled { "garbage.jsxbin" } else { "garbage.jsx" };
|
||||
checked("script garbage", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": name}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_scripts_never_panic(pieces in prop::collection::vec(prop::sample::select(SCRIPT_PIECES.to_vec()), 0..120)) {
|
||||
checked("generated script", json!({"data": pieces.concat()}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_scripts_never_panic(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, any::<u8>()), 0..12)) {
|
||||
let mut bytes = SCRIPT_SAMPLE.as_bytes().to_vec();
|
||||
for (at, b) in edits {
|
||||
let n = bytes.len();
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
bytes.truncate(cut);
|
||||
checked("mutated script", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "sample.jsx"}))?;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! #1604: a document opened by a path relative to the working directory finds its relatively
|
||||
//! linked images, as when opened by its absolute path. Its own test binary: it changes the
|
||||
//! process's working directory.
|
||||
// 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)]
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn a_relative_path_finds_relative_links() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-relative-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
std::fs::write(dir.join("ext.png"), &png).unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image x="0" y="0" width="40" height="40" href="ext.png"/></svg>"#;
|
||||
std::fs::write(dir.join("linked.svg"), svg).unwrap();
|
||||
std::env::set_current_dir(&dir).unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"path": "linked.svg"})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert_eq!(r["missingLinks"], json!([]), "{r}");
|
||||
}
|
||||
@@ -22,6 +22,28 @@ fn a_journal_with_a_key_no_longer_read_still_replays() {
|
||||
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1));
|
||||
}
|
||||
|
||||
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
|
||||
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
|
||||
#[test]
|
||||
fn effect_params_no_longer_taken_still_replay_and_open() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let old = [
|
||||
("effect.apply", json!({"effect": "blur.gaussian", "params": {"blurr": 3, "radius": "big"}})),
|
||||
("effect.setParams", json!({"index": 0, "params": {"amount": 1e308}})),
|
||||
];
|
||||
for (id, params) in &old {
|
||||
assert!(s.check_params(id, params).is_err(), "{id}");
|
||||
s.execute(id, params).unwrap();
|
||||
}
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
let stored = |d: &vectorcraft_doc::Document| d.layers[0].children().unwrap()[0].appearance.effects[0].params.clone();
|
||||
assert_eq!(stored(&back), stored(doc));
|
||||
assert_eq!(stored(&back)["blurr"], json!(3));
|
||||
}
|
||||
|
||||
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
|
||||
/// sent back through the control channel or MCP.
|
||||
#[test]
|
||||
|
||||
@@ -158,8 +158,11 @@ impl GradientDef {
|
||||
if self.gradient.stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Stops come last first: reversed, stops sharing a ramp point keep their order.
|
||||
self.gradient.stops.reverse();
|
||||
// Stops are listed along the ramp from either end: listed downwards they're reversed, so
|
||||
// stops sharing a ramp point keep their order (and each its midpoint, to the next stop).
|
||||
if self.gradient.stops.first().zip(self.gradient.stops.last()).is_some_and(|(first, last)| first.offset > last.offset) {
|
||||
self.gradient.stops.reverse();
|
||||
}
|
||||
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
|
||||
Some((self.name, self.gradient))
|
||||
}
|
||||
|
||||
@@ -410,6 +410,19 @@ fn stops_sharing_a_ramp_point_keep_their_order() {
|
||||
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_listed_upwards_keep_the_midpoint_of_the_one_that_starts_the_ramp() {
|
||||
// GUARDS: reversing a list that runs upwards swaps two stops at 0, and the ramp to 100 takes the
|
||||
// midpoint of the zero-length part between them.
|
||||
let defs = "%AI5_BeginGradient: (U)\n(U) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n\
|
||||
%_0 0 0 0 1 1 1 2 1 6 87 0 Bs\n%_0 0 0 0 0 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||||
let art_ = format!("{}Bb\n1 (U) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
|
||||
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
|
||||
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
|
||||
let midpoints: Vec<_> = g.gradient.stops.iter().map(|s| s.midpoint).collect();
|
||||
assert_eq!(midpoints, [0.5, 0.87, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_anchor_data_reads() {
|
||||
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
|
||||
|
||||
@@ -366,9 +366,9 @@ impl Out {
|
||||
/// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which
|
||||
/// clips nothing.
|
||||
fn clip(&mut self, path: BezPath, rule: FillRule) -> Option<Arc<Clip>> {
|
||||
let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..)));
|
||||
let around = path.bounding_box().inflate(0.5, 0.5);
|
||||
if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) {
|
||||
// Only a polygon that really covers the page: a diamond touching its edges has the page as
|
||||
// its bounding box but cuts its corners away (#1542).
|
||||
if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) {
|
||||
return None;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
|
||||
@@ -248,6 +248,12 @@ fn clips_become_clipping_groups() {
|
||||
// eoclip of a path.
|
||||
let o = objects(&read("10 10 moveto 30 10 lineto 30 30 lineto closepath eoclip newpath 0 0 50 50 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }));
|
||||
// #1542: a diamond touching the page edges has the page as its bounding box but still clips.
|
||||
let o = objects(&read("50 0 moveto 100 50 lineto 50 100 lineto 0 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
|
||||
// A turned square around the whole page clips nothing.
|
||||
let o = objects(&read("50 -60 moveto 160 50 lineto 50 160 lineto -60 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -467,6 +473,22 @@ fn the_bounding_box_comes_from_the_header_or_the_trailer() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("first of the file's 2 pages")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// #1552: a Type 42 font's glyph space is one unit an em, so its identity FontMatrix scaled to 12
|
||||
/// sets 12 pt type (a Type 1's 0.001 matrix needs the 1000-unit space).
|
||||
#[test]
|
||||
fn type_42_fonts_set_type_at_their_size() {
|
||||
for (kind, matrix) in [(42, "[1 0 0 1 0 0]"), (1, "[0.001 0 0 0.001 0 0]")] {
|
||||
let body = format!(
|
||||
"/T 10 dict begin /FontName /T def /FontType {kind} def /FontMatrix {matrix} def /Encoding StandardEncoding def currentdict end /T exch definefont pop \
|
||||
/T findfont 12 scalefont setfont 10 20 moveto (Hi) show"
|
||||
);
|
||||
let r = read(&body);
|
||||
let NodeKind::Text(t) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||||
let size = t.runs[0].style.size * t.xf.determinant().abs().sqrt();
|
||||
assert!((size - 12.0).abs() < 1e-6, "FontType {kind}: {size} pt");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_font_programs_are_skipped() {
|
||||
let body = "/MyFont 10 dict begin /FontName /MyFont def /FontMatrix [0.001 0 0 0.001 0 0] def currentdict end currentfile eexec\n\
|
||||
|
||||
@@ -403,7 +403,10 @@ impl Interp<'_> {
|
||||
let f = font.borrow();
|
||||
let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string());
|
||||
let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
|
||||
Ok((name, m * Affine::scale(1000.0)))
|
||||
// Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em
|
||||
// (its FontMatrix, usually the identity, is already the em's).
|
||||
let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 };
|
||||
Ok((name, m * Affine::scale(units)))
|
||||
}
|
||||
|
||||
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
|
||||
|
||||
@@ -15,6 +15,11 @@ serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
|
||||
# A plain fsync where a volume refuses the full flush (`sync_file`, atomic.rs). Already in
|
||||
# Cargo.lock (tempfile), so this adds no new crate.
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
rustix = { workspace = true, features = ["std", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
+264
-9
@@ -1,6 +1,13 @@
|
||||
//! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed
|
||||
//! or interrupted write never leaves a damaged file behind (the old one stays as it was).
|
||||
//! [`write_new_with`] creates a file the same way without ever replacing one.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! 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).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write as _};
|
||||
@@ -13,18 +20,88 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Write `path` atomically with `fill`, which writes the content into the temporary file. When
|
||||
/// anything fails (`fill`, flushing, the rename) the temporary file is removed and `path` is left
|
||||
/// untouched.
|
||||
/// `fill` or flushing fails the temporary file is removed and `path` is left untouched. When the
|
||||
/// folder refuses the temporary file or the rename, the content is copied into `path` instead (see
|
||||
/// the module docs).
|
||||
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
write_atomic_stepping(path, fill, create_new, |tmp, target| std::fs::rename(tmp, target))
|
||||
}
|
||||
|
||||
fn create_new(path: &Path) -> io::Result<File> {
|
||||
OpenOptions::new().write(true).create_new(true).open(path)
|
||||
}
|
||||
|
||||
/// [`write_atomic_with`], with `create` making the temporary file beside the target and `rename`
|
||||
/// moving it over the target.
|
||||
fn write_atomic_stepping(
|
||||
path: &Path,
|
||||
fill: impl FnOnce(&mut File) -> io::Result<()>,
|
||||
create: impl FnOnce(&Path) -> io::Result<File>,
|
||||
rename: impl FnOnce(&Path, &Path) -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
let target = resolve_link(path)?;
|
||||
let tmp = temp_path(&target)?;
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = fill(&mut f).and_then(|()| f.sync_all());
|
||||
let beside = temp_path(&target)?;
|
||||
// `refused`: why the folder takes no temporary file (it is in the system's folder then).
|
||||
let (tmp, mut f, refused) = match create(&beside) {
|
||||
Ok(f) => (beside, f, None),
|
||||
// A folder that isn't there, a full volume: nothing can be saved there.
|
||||
Err(e) if matches!(e.kind(), io::ErrorKind::NotFound | io::ErrorKind::StorageFull | io::ErrorKind::QuotaExceeded) => return Err(e),
|
||||
Err(e) => {
|
||||
let Some(tmp) = beside.file_name().map(|n| std::env::temp_dir().join(n)) else { return Err(e) };
|
||||
let Ok(f) = create_private(&tmp) else { return Err(e) };
|
||||
(tmp, f, Some(e))
|
||||
}
|
||||
};
|
||||
let filled = fill(&mut f).and_then(|()| sync_file(&f));
|
||||
drop(f);
|
||||
filled.and_then(|()| keep_permissions(&target, &tmp)).and_then(|()| std::fs::rename(&tmp, &target)).inspect_err(|_| {
|
||||
if let Err(e) = filled {
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
return Err(e);
|
||||
}
|
||||
let refused = match refused {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
// Best effort: a volume without Unix permissions (SMB, FAT) refuses them, and has none
|
||||
// to keep.
|
||||
let _ = keep_permissions(&target, &tmp);
|
||||
let Err(e) = rename(&tmp, &target) else { return Ok(()) };
|
||||
e
|
||||
}
|
||||
};
|
||||
write_over(&tmp, &target, refused)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 {
|
||||
// Best effort: the temporary file is all there is to clean up.
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
return Err(refused);
|
||||
};
|
||||
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.
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
/// Create `path` with the content `fill` writes, never replacing a file or a link of that name. The
|
||||
@@ -45,7 +122,7 @@ fn write_new_linking(
|
||||
) -> io::Result<()> {
|
||||
let tmp = temp_path(path)?;
|
||||
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
|
||||
let filled = fill(&mut f).and_then(|()| f.sync_all());
|
||||
let filled = fill(&mut f).and_then(|()| sync_file(&f));
|
||||
drop(f);
|
||||
let placed = filled.and_then(|()| match link(&tmp, path) {
|
||||
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(&tmp, path),
|
||||
@@ -60,7 +137,7 @@ fn write_new_linking(
|
||||
/// removes it again.
|
||||
fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
|
||||
let mut to = OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| to.sync_all());
|
||||
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| sync_file(&to));
|
||||
drop(to);
|
||||
if copied.is_err() {
|
||||
// Best effort: the part copied is all there is to clean up.
|
||||
@@ -69,6 +146,63 @@ fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
|
||||
copied
|
||||
}
|
||||
|
||||
/// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
|
||||
/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares, #1620) this falls back
|
||||
/// to a plain `fsync`. Any other error, and a failed `fsync`, is returned as it is.
|
||||
pub fn sync_file(file: &File) -> io::Result<()> {
|
||||
match full_sync(file) {
|
||||
Err(e) if full_flush_unsupported(&e) => plain_fsync(file),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` is a volume refusing a full flush. Never off Apple platforms: there `sync_all` is
|
||||
/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` to `Uncategorized`.
|
||||
fn full_flush_unsupported(e: &io::Error) -> bool {
|
||||
cfg!(target_vendor = "apple") && e.raw_os_error() == Some(ENOTSUP)
|
||||
}
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
fn plain_fsync(file: &File) -> io::Result<()> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
PLAIN_FSYNCS.with(|c| c.set(c.get() + 1));
|
||||
if let Some(errno) = PLAIN_FSYNC_FAILS.with(std::cell::Cell::get) {
|
||||
return Err(io::Error::from_raw_os_error(errno));
|
||||
}
|
||||
}
|
||||
rustix::fs::fsync(file).map_err(io::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
fn plain_fsync(file: &File) -> io::Result<()> {
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
/// What macOS answers when a volume can't make a full flush (SMB shares).
|
||||
#[cfg(target_vendor = "apple")]
|
||||
const ENOTSUP: i32 = 45;
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
const ENOTSUP: i32 = 95;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: every full flush on this thread fails as on an SMB share mounted by macOS.
|
||||
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: how many plain `fsync` fallbacks were made on this thread.
|
||||
static PLAIN_FSYNCS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A symbolic link keeps pointing at its file: write the file it names.
|
||||
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
@@ -137,6 +271,127 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A save to an SMB share (#1620): macOS refuses the full flush there with `ENOTSUP`, so the
|
||||
/// file is flushed with a plain `fsync` and saved. When that fails too the save fails as before,
|
||||
/// the old file stays and no temporary file is left. Elsewhere `sync_all` is already a plain
|
||||
/// `fsync`: a refusal is an error like any other.
|
||||
#[test]
|
||||
fn a_volume_that_refuses_the_full_flush_still_saves() {
|
||||
let d = dir("no-full-sync");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
let apple = cfg!(target_vendor = "apple");
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(true));
|
||||
let before = PLAIN_FSYNCS.with(std::cell::Cell::get);
|
||||
let saved = write_atomic(&path, b"new");
|
||||
let created = write_new_with(&d.join("new.otf"), |f| f.write_all(b"created"));
|
||||
let fsyncs = PLAIN_FSYNCS.with(std::cell::Cell::get) - before;
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(Some(5)));
|
||||
let failed = write_atomic(&path, b"lost");
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(None));
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(false));
|
||||
assert_eq!(saved.is_ok(), apple, "{saved:?}");
|
||||
assert_eq!(created.is_ok(), apple, "{created:?}");
|
||||
assert_eq!(fsyncs, if apple { 2 } else { 0 }, "each refused full flush falls back to fsync");
|
||||
assert!(failed.is_err(), "a failed fsync still fails the save");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), if apple { &b"new"[..] } else { b"original" });
|
||||
let mut left = leftovers(&d, "doc.vectorcraft");
|
||||
left.retain(|n| n != "new.otf");
|
||||
assert!(left.is_empty(), "no temporary file is left: {left:?}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
fn denied<T>() -> io::Result<T> {
|
||||
Err(io::Error::new(io::ErrorKind::PermissionDenied, "refused by the share"))
|
||||
}
|
||||
|
||||
/// A folder that refuses the rename over the file (#1620: some network shares): the new content,
|
||||
/// whole in the temporary file, is copied into the file itself. A new file is created.
|
||||
#[test]
|
||||
fn a_refused_rename_copies_the_content_into_the_file() {
|
||||
let d = dir("no-rename");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original, and longer").unwrap();
|
||||
write_atomic_stepping(&path, |f| f.write_all(b"new"), create_new, |_, _| denied()).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
write_atomic_stepping(&d.join("other.vectorcraft"), |f| f.write_all(b"other"), create_new, |_, _| denied()).unwrap();
|
||||
assert_eq!(std::fs::read(d.join("other.vectorcraft")).unwrap(), b"other");
|
||||
assert_eq!(leftovers(&d, "doc.vectorcraft"), ["other.vectorcraft"], "the temporary files are removed");
|
||||
// A failed write never gets as far as the file.
|
||||
let r = write_atomic_stepping(&path, |f| f.write_all(b"half").and(Err(io::Error::other("disk full"))), create_new, |_, _| denied());
|
||||
assert_eq!(r.unwrap_err().to_string(), "disk full");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A refused rename where the file can't be written either (here: a folder of that name): the
|
||||
/// rename's error, nothing changed and no temporary file left.
|
||||
#[test]
|
||||
fn a_refused_rename_over_a_file_that_cannot_be_written_changes_nothing() {
|
||||
let d = dir("no-rename-no-write");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
let r = write_atomic_stepping(&path, |f| f.write_all(b"new"), create_new, |_, _| denied());
|
||||
assert_eq!(r.unwrap_err().to_string(), "refused by the share");
|
||||
assert!(path.is_dir() && leftovers(&d, "doc.vectorcraft").is_empty());
|
||||
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.
|
||||
#[test]
|
||||
fn a_failed_copy_into_the_file_keeps_the_temporary_file() {
|
||||
let d = dir("no-rename-copy-fails");
|
||||
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,
|
||||
|_, _| {
|
||||
// From here on flushing fails (also the plain `fsync` macOS falls back to).
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(true));
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(Some(5)));
|
||||
denied()
|
||||
},
|
||||
);
|
||||
REFUSE_FULL_SYNC.with(|c| c.set(false));
|
||||
PLAIN_FSYNC_FAILS.with(|c| c.set(None));
|
||||
let message = r.unwrap_err().to_string();
|
||||
let left = leftovers(&d, "doc.vectorcraft");
|
||||
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");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A folder that takes no temporary file (#1620: a share that refuses hidden or new names) but
|
||||
/// lets the file be written: the content goes through the system's temporary folder, and no
|
||||
/// rename is tried. A folder that isn't there is still an error.
|
||||
#[test]
|
||||
fn a_folder_that_refuses_the_temporary_file_still_saves() {
|
||||
let d = dir("no-temp");
|
||||
let path = d.join("doc.vectorcraft");
|
||||
std::fs::write(&path, b"original, and longer").unwrap();
|
||||
let renamed = std::cell::Cell::new(false);
|
||||
let rename = |_: &Path, _: &Path| {
|
||||
renamed.set(true);
|
||||
Ok(())
|
||||
};
|
||||
write_atomic_stepping(&path, |f| f.write_all(b"new"), |_| denied(), rename).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
assert!(!renamed.get() && leftovers(&d, "doc.vectorcraft").is_empty());
|
||||
// The file can't be written either (a folder of that name): the folder's error.
|
||||
let folder = d.join("folder");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
let r = write_atomic_stepping(&folder, |f| f.write_all(b"new"), |_| denied(), |_, _| Ok(()));
|
||||
assert_eq!(r.unwrap_err().to_string(), "refused by the share");
|
||||
let r = write_atomic_stepping(&path, |f| f.write_all(b"lost"), |_| Err(io::Error::from(io::ErrorKind::NotFound)), |_, _| Ok(()));
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::NotFound));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_new_never_replaces_what_is_there() {
|
||||
let d = dir("new");
|
||||
|
||||
@@ -45,7 +45,7 @@ use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use atomic::{write_atomic, write_atomic_with, write_new_with};
|
||||
pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with};
|
||||
use stream::{Gz, Indent, KeyScan, Out};
|
||||
|
||||
/// v4: large data after the JSON (v1 to v3 files still load).
|
||||
@@ -243,13 +243,32 @@ impl SaveOptions {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`]).
|
||||
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`] and
|
||||
/// [`PRETTY_MAX_BYTES`]).
|
||||
pub fn for_doc(doc: &Document) -> Self {
|
||||
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
|
||||
Self { pretty: objects <= PRETTY_MAX_OBJECTS, ..Self::default() }
|
||||
Self { pretty: objects <= PRETTY_MAX_OBJECTS && compact_size_at_most(doc, PRETTY_MAX_BYTES), ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `doc` at most `limit` bytes as compact JSON? Serializing stops as soon as it is past the
|
||||
/// limit, so a large document costs no more than `limit` bytes of work.
|
||||
fn compact_size_at_most(doc: &Document, limit: usize) -> bool {
|
||||
struct Capped {
|
||||
left: usize,
|
||||
}
|
||||
impl Write for Capped {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.left = self.left.checked_sub(buf.len()).ok_or_else(|| std::io::Error::other("over the limit"))?;
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
serde_json::to_writer(Capped { left: limit }, doc).is_ok()
|
||||
}
|
||||
|
||||
/// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working
|
||||
/// copies are left out ([`Document::without_edit_modes`]).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
@@ -260,6 +279,11 @@ pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
/// compact, where indentation would multiply the file size and slow opening down.
|
||||
pub const PRETTY_MAX_OBJECTS: usize = 2000;
|
||||
|
||||
/// Documents whose compact JSON is larger than this are saved compact even with few objects:
|
||||
/// pretty-printing spreads every path anchor over several lines, so traced or generated art (in
|
||||
/// layers, symbols or patterns alike) would grow about tenfold.
|
||||
pub const PRETTY_MAX_BYTES: usize = 1 << 20;
|
||||
|
||||
/// Serialize for saving to disk: pretty when small, compact when large.
|
||||
pub fn save_file(doc: &Document) -> Vec<u8> {
|
||||
save_with(doc, &SaveOptions::for_doc(doc)).unwrap_or_default()
|
||||
|
||||
@@ -8,9 +8,11 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::links::LinkInfo;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_format::{ICC_MIME, SaveOptions, load, load_file, pdf_content, save_with};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind, Symbol};
|
||||
use vectorcraft_format::{ICC_MIME, PRETTY_MAX_BYTES, PRETTY_MAX_OBJECTS, SaveOptions, load, load_file, pdf_content, save_file, save_with};
|
||||
use vectorcraft_geom::path::{PathData, SubPath};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||
|
||||
fn json_of(bytes: &[u8]) -> Value {
|
||||
serde_json::from_slice(bytes).unwrap()
|
||||
@@ -82,3 +84,66 @@ fn embedded_profiles_and_pdf_round_trip() {
|
||||
let damaged = String::from_utf8(plain).unwrap().replacen("AAECA/o=", "not base64!", 1);
|
||||
assert!(load_file(damaged.as_bytes()).unwrap().profiles.is_empty());
|
||||
}
|
||||
|
||||
/// A document of `paths` open paths with `anchors` anchors each.
|
||||
fn paths_doc(paths: usize, anchors: usize) -> Document {
|
||||
let mut b = DocBuilder::new(1000.0, 1000.0);
|
||||
for i in 0..paths {
|
||||
let points: Vec<Point> = (0..anchors).map(|j| Point::new(j as f64, i as f64 + (j % 2) as f64)).collect();
|
||||
let node = b.path_node(PathData::single(SubPath::polyline(&points, false)), Default::default());
|
||||
b.add(node);
|
||||
}
|
||||
b.doc
|
||||
}
|
||||
|
||||
fn is_pretty(bytes: &[u8]) -> bool {
|
||||
bytes.windows(3).any(|w| w == b"\n ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_documents_are_saved_pretty() {
|
||||
let d = paths_doc(10, 10);
|
||||
assert!(SaveOptions::for_doc(&d).pretty);
|
||||
assert!(is_pretty(&save_file(&d)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_objects_are_saved_compact() {
|
||||
let d = paths_doc(PRETTY_MAX_OBJECTS + 1, 2);
|
||||
assert!(!SaveOptions::for_doc(&d).pretty);
|
||||
}
|
||||
|
||||
/// Anchors per path that make a document of `paths` paths about `bytes` of compact JSON.
|
||||
fn anchors_for(paths: usize, bytes: usize) -> usize {
|
||||
let per_anchor = serde_json::to_vec(&paths_doc(1, 1000)).unwrap().len() / 1000;
|
||||
bytes / per_anchor / paths
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn few_objects_with_many_anchors_are_saved_compact() {
|
||||
let d = paths_doc(3, anchors_for(3, 2 * PRETTY_MAX_BYTES));
|
||||
assert!(!SaveOptions::for_doc(&d).pretty, "3 objects, but about {} bytes", 2 * PRETTY_MAX_BYTES);
|
||||
let bytes = save_file(&d);
|
||||
assert!(!is_pretty(&bytes));
|
||||
assert_eq!(load(&bytes).unwrap().layers[0].count(), d.layers[0].count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_under_the_size_limit_stay_pretty() {
|
||||
let d = paths_doc(2, anchors_for(2, PRETTY_MAX_BYTES / 2));
|
||||
assert!(SaveOptions::for_doc(&d).pretty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_outside_the_layers_counts_too() {
|
||||
// Generated art kept in a symbol: one instance-sized document, a symbol full of anchors.
|
||||
let mut d = paths_doc(1, 4);
|
||||
let mut art = paths_doc(1, anchors_for(1, 2 * PRETTY_MAX_BYTES));
|
||||
let path = Arc::clone(&art.layers[0]);
|
||||
art.layers.clear();
|
||||
d.symbols.push(Symbol { name: "Leaves".into(), art: path });
|
||||
assert!(!SaveOptions::for_doc(&d).pretty);
|
||||
let bytes = save_file(&d);
|
||||
assert!(!is_pretty(&bytes));
|
||||
assert_eq!(load(&bytes).unwrap().symbols.len(), 1);
|
||||
}
|
||||
@@ -155,8 +155,8 @@ impl Corner {
|
||||
let (h_out, h_in) = match kind {
|
||||
// An arc tangent to both sides, turning by 180° less the angle between them there.
|
||||
CornerKind::Round => {
|
||||
let h = radius * arc_handle(-cos);
|
||||
(start - du * h, end - dv * h)
|
||||
let (hu, hv) = self.round_handles(radius, (start, du), (end, dv));
|
||||
(start - du * hu, end - dv * hv)
|
||||
}
|
||||
// An arc centred on the corner through both ends, spanning the angle.
|
||||
CornerKind::InvertedRound => {
|
||||
@@ -169,6 +169,33 @@ impl Corner {
|
||||
}
|
||||
}
|
||||
|
||||
impl Corner {
|
||||
/// The handle lengths of a round cut of `radius` between `start` and `end` (each a point and
|
||||
/// the direction its side runs on in there). Between straight sides they are the arc's. A
|
||||
/// curved side has turned by the time the cut meets it, so the arc's handles would reach past
|
||||
/// where the tangents at the cut's ends cross and the cut would stick out of the corner
|
||||
/// (#1512): there each handle is the share an arc takes of its tangent, up to that crossing
|
||||
/// (a third of the way to the other end where the tangents don't cross between them).
|
||||
fn round_handles(&self, radius: f64, (start, du): (Point, Vec2), (end, dv): (Point, Vec2)) -> (f64, f64) {
|
||||
let cos = du.dot(dv);
|
||||
let arc = arc_handle(-cos);
|
||||
if self.side_u.straight() && self.side_v.straight() {
|
||||
return (radius * arc, radius * arc);
|
||||
}
|
||||
// start − du·a = end − dv·b is where the tangents cross.
|
||||
let (cross, w) = (du.cross(dv), start - end);
|
||||
let (a, b) = (w.cross(dv) / cross, w.cross(du) / cross);
|
||||
if cross.abs() > MIN_SINE && a > EPS && b > EPS && a.is_finite() && b.is_finite() {
|
||||
// An arc's handle over its tangent's length, radius · tan(turn / 2).
|
||||
let share = arc * (1.0 - cos) / cross.abs();
|
||||
(a * share, b * share)
|
||||
} else {
|
||||
let third = start.distance(end) / 3.0;
|
||||
(third, third)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
|
||||
/// and the handles between them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -401,6 +428,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A round cut next to a curve stays inside its corner, however large (#1512): its handles
|
||||
/// stop short of where the tangents at its ends cross, so it doesn't stick out past the sides.
|
||||
#[test]
|
||||
fn a_round_cut_next_to_a_curve_stays_inside_its_corner() {
|
||||
// A square whose top side arches up, meeting the upright sides at an angle.
|
||||
let mut sp =
|
||||
SubPath::polyline(&[Point::new(100.0, 150.0), Point::new(300.0, 150.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)], true);
|
||||
sp.anchors[0].h_out = Point::new(120.0, 20.0);
|
||||
sp.anchors[1].h_in = Point::new(280.0, 20.0);
|
||||
let path = PathData::single(sp);
|
||||
let bounds = path.bounds().unwrap().inflate(1e-6, 1e-6);
|
||||
for radius in [10.0, 60.0, 200.0, 1e6] {
|
||||
let (cut, _) = cut_corners(&path, &[radius, radius], &[]);
|
||||
let b = cut.bounds().unwrap();
|
||||
assert!(bounds.union(b) == bounds, "radius {radius}: {b:?} is outside {bounds:?}");
|
||||
// Each cut's handles end before its tangents cross: the cut turns one way only.
|
||||
let a = &cut.subpaths[0].anchors;
|
||||
for (from, to) in [(a[a.len() - 1], a[0]), (a[1], a[2])] {
|
||||
let (t0, t1, chord) = (from.h_out - from.p, to.p - to.h_in, to.p - from.p);
|
||||
assert!(t0.cross(chord) * chord.cross(t1) > 0.0 && t0.cross(t1) * t0.cross(chord) > 0.0, "radius {radius}: {from:?} {to:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverted round and chamfer cuts end where the round one does, at any angle.
|
||||
#[test]
|
||||
fn kinds_share_their_ends() {
|
||||
|
||||
@@ -6,6 +6,47 @@ use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
|
||||
|
||||
use crate::path::{FillRule, PathData};
|
||||
|
||||
/// Does the filled area of `path` cover all of `r` shrunk by `tol` on every side? Answered for one
|
||||
/// convex polygon (a single subpath of straight segments), which covers the rect when it contains
|
||||
/// its four corners; any other path gives `false`, so a caller treats it as not covering.
|
||||
pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
for (i, el) in path.elements().iter().enumerate() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) if i == 0 => pts.push(p),
|
||||
PathEl::LineTo(p) if i > 0 => pts.push(p),
|
||||
PathEl::ClosePath if i > 0 => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
if pts.len() > 1 && pts.first() == pts.last() {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
// Convex: every turn goes the same way (straight runs allowed).
|
||||
let n = pts.len();
|
||||
let mut sign = 0.0f64;
|
||||
for i in 0..n {
|
||||
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
|
||||
let cross = (b - a).cross(c - b);
|
||||
if cross.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = cross.signum();
|
||||
} else if cross.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let inner = r.inflate(-tol, -tol);
|
||||
sign != 0.0
|
||||
&& [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1), Point::new(inner.x0, inner.y1)]
|
||||
.into_iter()
|
||||
.all(|c| fill_contains(path, FillRule::NonZero, c))
|
||||
}
|
||||
|
||||
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
|
||||
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
|
||||
let w = closed(path).winding(p);
|
||||
@@ -94,6 +135,32 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::shapes;
|
||||
|
||||
#[test]
|
||||
fn polygon_covers_rect_only_for_convex_polygons_around_it() {
|
||||
let page = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let poly = |pts: &[(f64, f64)]| {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to(pts[0]);
|
||||
for &p in &pts[1..] {
|
||||
b.line_to(p);
|
||||
}
|
||||
b.close_path();
|
||||
b
|
||||
};
|
||||
assert!(polygon_covers_rect(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), page, 0.01));
|
||||
assert!(polygon_covers_rect(&poly(&[(50.0, -60.0), (160.0, 50.0), (50.0, 160.0), (-60.0, 50.0)]), page, 0.01), "turned, around it");
|
||||
// The page's bounding box, but its corners are cut away.
|
||||
assert!(!polygon_covers_rect(&poly(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]), page, 0.01));
|
||||
// Contains the corners but notches into the page: not convex.
|
||||
assert!(!polygon_covers_rect(&poly(&[(-1.0, -1.0), (101.0, -1.0), (101.0, 101.0), (50.0, 40.0), (-1.0, 101.0)]), page, 0.01));
|
||||
// Curves and several subpaths aren't judged.
|
||||
assert!(!polygon_covers_rect(&kurbo::Circle::new((50.0, 50.0), 200.0).to_path(0.1), page, 0.01));
|
||||
let mut two = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
two.extend(poly(&[(10.0, 10.0), (20.0, 10.0), (20.0, 20.0)]));
|
||||
assert!(!polygon_covers_rect(&two, page, 0.01));
|
||||
assert!(!polygon_covers_rect(&BezPath::new(), page, 0.01));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_and_stroke() {
|
||||
let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)).to_bezpath();
|
||||
|
||||
@@ -128,7 +128,7 @@ impl Headless {
|
||||
// A key the command's description doesn't name is an error, not a default (#1442).
|
||||
match HOST_COMMANDS.iter().find(|c| c.0 == id) {
|
||||
Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params),
|
||||
None => vectorcraft_engine::check_params(id, params),
|
||||
None => self.session.check_params(id, params),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
match id {
|
||||
|
||||
+113
-19
@@ -26,6 +26,8 @@ use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_sh
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// Mixed into an image's cache key when it is read white inside an alpha soft mask.
|
||||
const WHITE_KEY: u128 = 0x7717_e000_a1fa;
|
||||
/// The name of the paths text imports as.
|
||||
const TEXT_OUTLINES: &str = "<Text Outlines>";
|
||||
/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects.
|
||||
@@ -197,6 +199,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data);
|
||||
let cache = InterpreterCache::new();
|
||||
let mut x = 0.0;
|
||||
// Where the next page's art may begin (a `.ai` page's art reaches past its artboard).
|
||||
let mut clear = f64::NEG_INFINITY;
|
||||
// Every page only text over private data: a file saved without its PDF part shows a notice
|
||||
// there, not its art. A page that states where its art is isn't that notice.
|
||||
let (mut only_text, mut art_boxes) = (true, false);
|
||||
@@ -220,6 +224,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
|
||||
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
|
||||
b.page = ab;
|
||||
// The first id of this page's art.
|
||||
let first_id = b.next;
|
||||
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
|
||||
if route {
|
||||
let mut seen = HashSet::new();
|
||||
@@ -267,6 +273,33 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
if clipped {
|
||||
b.warn(PAST_PAGE_NOTE);
|
||||
}
|
||||
// A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art
|
||||
// reaches far to the left would lay it over the page before it in the row: there it would
|
||||
// be taken for that page's art (by the check against the editing data, which then finds
|
||||
// art its layers don't have there, and by `place_pages`). Such a page moves right until its
|
||||
// art clears the art before it.
|
||||
let mut art_right = f64::NEG_INFINITY;
|
||||
if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) {
|
||||
// Art at a coordinate that isn't a number (a damaged file) moves nothing.
|
||||
let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0);
|
||||
if dx > 0.0 {
|
||||
let shift = Affine::translate((dx, 0.0));
|
||||
for (_, v) in &mut parts {
|
||||
for n in v.iter_mut() {
|
||||
Arc::make_mut(n).transform(shift, false);
|
||||
}
|
||||
}
|
||||
// What the page's lines of type were is told where their glyphs are (the outlines
|
||||
// stroked on them are looked for there), so that moves with them.
|
||||
for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) {
|
||||
for ink in &mut facts.ink {
|
||||
*ink = *ink + kurbo::Vec2::new(dx, 0.0);
|
||||
}
|
||||
}
|
||||
ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1);
|
||||
}
|
||||
art_right = art.x1 + dx;
|
||||
}
|
||||
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
|
||||
only_text &= ai && is_only_text(&children);
|
||||
art_boxes |= crate::pages::states_art(page);
|
||||
@@ -278,6 +311,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
|
||||
ab = art;
|
||||
}
|
||||
x = right + opts.artboard_gap;
|
||||
clear = right.max(art_right) + opts.artboard_gap;
|
||||
doc.artboards.push(Artboard {
|
||||
id: i as u32 + 1,
|
||||
name: format!("Artboard {}", i + 1),
|
||||
@@ -756,6 +790,10 @@ struct Builder<'p> {
|
||||
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
|
||||
pending: bool,
|
||||
nested: u32,
|
||||
/// Reading an alpha soft mask's art: images are read white with their own alpha, so the
|
||||
/// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape,
|
||||
/// rasterizes masks into such images).
|
||||
white_images: bool,
|
||||
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
|
||||
last_fill: Option<(NodeId, BezPath)>,
|
||||
/// What each line of type made tells beyond its text object ([`crate::import_lines`]).
|
||||
@@ -898,6 +936,7 @@ impl<'p> Builder<'p> {
|
||||
marked: vec![],
|
||||
pending: false,
|
||||
nested: 0,
|
||||
white_images: false,
|
||||
last_fill: None,
|
||||
lines: HashMap::new(),
|
||||
}
|
||||
@@ -1202,11 +1241,50 @@ impl<'p> Builder<'p> {
|
||||
}
|
||||
|
||||
fn flush_text(&mut self) {
|
||||
let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return };
|
||||
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
n.opacity = opacity;
|
||||
self.lines.insert(n.id, facts);
|
||||
self.push_node(n);
|
||||
let Some(mut line) = self.text.take() else { return };
|
||||
let held = std::mem::take(&mut line.held);
|
||||
if let Some((t, opacity, facts)) = line.finish() {
|
||||
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
|
||||
n.opacity = opacity;
|
||||
self.lines.insert(n.id, facts);
|
||||
self.push_node(n);
|
||||
}
|
||||
// The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke).
|
||||
for n in held {
|
||||
self.push_node(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph
|
||||
/// 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 };
|
||||
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) {
|
||||
return false;
|
||||
}
|
||||
let (p, opacity) = self.paint(paint, true);
|
||||
let Some(line) = &self.text else { return false };
|
||||
let Some(ink) = line.ink.last() else { return false };
|
||||
if line.held.len() + 1 != line.ink.len() || opacity < 0.999 || !line.last_fill().is_some_and(|f| *f == p) {
|
||||
return false;
|
||||
}
|
||||
let mut bp = path.clone();
|
||||
bp.apply_affine(transform);
|
||||
let b = bp.bounding_box();
|
||||
let tol = 0.01 * (ink.width() + ink.height()) + 0.01;
|
||||
if ![(b.x0, ink.x0), (b.y0, ink.y0), (b.x1, ink.x1), (b.y1, ink.y1)].iter().all(|(a, c)| (a - c).abs() <= tol) {
|
||||
return false;
|
||||
}
|
||||
let mut st = stroke_layer(p, opacity, props, mean_scale(transform));
|
||||
// Every painted path takes its turn in the scan's overprint list, a held one too.
|
||||
st.overprint = self.path_overprint();
|
||||
let n = Node::path(self.id(), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
|
||||
if let Some(line) = &mut self.text {
|
||||
line.held.push(n);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke).
|
||||
@@ -1267,7 +1345,10 @@ impl<'p> Builder<'p> {
|
||||
return s.clone();
|
||||
}
|
||||
let page = self.page;
|
||||
let spec = match self.nested(page, |b| m.interpret(b)) {
|
||||
let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha);
|
||||
let art = self.nested(page, |b| m.interpret(b));
|
||||
self.white_images = saved;
|
||||
let spec = match art {
|
||||
Some(art) => {
|
||||
let [r, g, b, _] = m.background_color().to_rgba().components();
|
||||
let backdrop = luminance(&Color::rgb(r, g, b));
|
||||
@@ -1298,10 +1379,15 @@ impl<'p> Builder<'p> {
|
||||
{
|
||||
return Some(Paint::Pattern { pattern: name.clone(), xf });
|
||||
}
|
||||
// A swatch is shared with uses outside an alpha mask, and pattern paints keep their
|
||||
// colours there: its images are read as they are.
|
||||
let white = std::mem::take(&mut self.white_images);
|
||||
let art = self.nested(Rect::ZERO, |b| {
|
||||
// A cell whose content can't be decoded is empty.
|
||||
let _ = t.interpret(b, Affine::IDENTITY, stroke);
|
||||
})?;
|
||||
});
|
||||
self.white_images = white;
|
||||
let art = art?;
|
||||
let sig = paints(&art);
|
||||
let entry = self.pattern_keys.entry(key).or_default();
|
||||
entry.0 += 1;
|
||||
@@ -1620,6 +1706,10 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
|
||||
self.flush_glyphs();
|
||||
if self.hold_glyph_stroke(path, transform, paint, draw_mode) {
|
||||
self.pending = false;
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
let overprint = self.path_overprint();
|
||||
@@ -1722,14 +1812,12 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
(*isolate, *knockout) = (i, k);
|
||||
}
|
||||
}
|
||||
let bb = clip_path.path.bounding_box();
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them.
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them. Only
|
||||
// a polygon that really covers it counts: a diamond touching the page edges has the page
|
||||
// as its bounding box but cuts its corners away (#1542).
|
||||
let redundant = clip_path.path.elements().len() <= 6
|
||||
&& bb.x0 <= self.page.x0 + 0.01
|
||||
&& bb.y0 <= self.page.y0 + 0.01
|
||||
&& bb.x1 >= self.page.x1 - 0.01
|
||||
&& bb.y1 >= self.page.y1 - 0.01
|
||||
&& self.page.area() > 0.0;
|
||||
&& self.page.area() > 0.0
|
||||
&& vectorcraft_geom::hit::polygon_covers_rect(&clip_path.path, self.page, 0.01);
|
||||
let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) };
|
||||
self.stack.push(Frame::new(kind));
|
||||
}
|
||||
@@ -1802,12 +1890,14 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.pending = false;
|
||||
match image {
|
||||
Image::Raster(r) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&r);
|
||||
let white = self.white_images;
|
||||
// Inside an alpha mask the same image is read again, white (its own cache key).
|
||||
let key = hayro_interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 };
|
||||
let st = r.stream();
|
||||
let (w, h) = (r.width(), r.height());
|
||||
// CMYK images keep their samples (read once per image).
|
||||
let known = self.cmyk_keys.contains(&key);
|
||||
let cmyk = if known {
|
||||
let known = !white && self.cmyk_keys.contains(&key);
|
||||
let cmyk = if known || white {
|
||||
None
|
||||
} else {
|
||||
crate::import_image::cmyk(st, w, h).unwrap_or_else(|why| {
|
||||
@@ -1826,7 +1916,8 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let dict = st.dict();
|
||||
let filters = st.filters();
|
||||
let cs_ok = dict.get::<Name<'_>>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray"));
|
||||
let jpeg = filters.len() == 1
|
||||
let jpeg = !white
|
||||
&& filters.len() == 1
|
||||
&& matches!(filters[0], hayro_syntax::Filter::DctDecode)
|
||||
&& cs_ok
|
||||
&& !dict.contains_key(b"SMask")
|
||||
@@ -1843,6 +1934,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let (w, h, sf) = (img.width(), img.height(), img.scale_factors());
|
||||
let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]);
|
||||
let rgba: Vec<u8> = match img {
|
||||
_ if white => a.into_iter().flat_map(|a| [255, 255, 255, a]).collect(),
|
||||
ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
|
||||
ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(),
|
||||
};
|
||||
@@ -1858,11 +1950,13 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf);
|
||||
}
|
||||
Image::Stencil(s) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&s);
|
||||
let white = self.white_images;
|
||||
let key = hayro_interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 };
|
||||
let mut decoded: Option<Decoded> = None;
|
||||
s.with_stencil(
|
||||
|luma, paint| {
|
||||
let rgb = match paint {
|
||||
_ if white => [255, 255, 255, 255],
|
||||
hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
|
||||
_ => [0, 0, 0, 255],
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_doc::{CharStyle, Node, ParaDirection, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_text::TextLayout;
|
||||
|
||||
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
|
||||
@@ -144,6 +144,9 @@ pub(crate) struct TextLine {
|
||||
gaps: Vec<(usize, f64)>,
|
||||
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
|
||||
advances: Vec<f64>,
|
||||
/// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far
|
||||
/// (written right after the line).
|
||||
pub held: Vec<Node>,
|
||||
}
|
||||
|
||||
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
|
||||
@@ -177,9 +180,15 @@ impl TextLine {
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
advances: vec![],
|
||||
held: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// The paint the last glyph is filled with.
|
||||
pub fn last_fill(&self) -> Option<&Paint> {
|
||||
self.runs.last()?.0.fill.as_ref()
|
||||
}
|
||||
|
||||
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
|
||||
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
|
||||
/// another line.
|
||||
@@ -350,6 +359,20 @@ impl TextLine {
|
||||
t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size);
|
||||
}
|
||||
if let Some(path) = path {
|
||||
// Evenly spaced type keeps its length along the path: the path runs through the
|
||||
// glyphs' origins, one curve each, to the end of the last glyph, and tracking makes up
|
||||
// the difference from the advances.
|
||||
if own.is_none() {
|
||||
let length: f64 = path.segments().take(self.baseline.len()).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).sum();
|
||||
let laid = vectorcraft_text::layout(db, &t);
|
||||
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size);
|
||||
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
|
||||
}
|
||||
}
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
|
||||
|
||||
@@ -78,6 +78,58 @@ fn the_mask(d: &Document) -> (Node, OpacityMask) {
|
||||
(n, m)
|
||||
}
|
||||
|
||||
/// The two pixels of the image a soft mask of kind `s` draws: black, opaque on the left and clear
|
||||
/// on the right (its own `/SMask`).
|
||||
fn mask_image_pixels(s: &str) -> [[u8; 4]; 2] {
|
||||
let g = first_extra(1);
|
||||
let group = stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Im0 {} 0 R >> >>",
|
||||
g + 1
|
||||
),
|
||||
"q 100 0 0 100 0 0 cm /Im0 Do Q",
|
||||
);
|
||||
let image = stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /SMask {} 0 R",
|
||||
g + 2
|
||||
),
|
||||
"000000000000>",
|
||||
);
|
||||
let smask =
|
||||
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>");
|
||||
let bytes = one_page(
|
||||
"/GS0 gs 1 0 0 rg 0 0 100 100 re f",
|
||||
&format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /{s} /G {g} 0 R >> >> >>"),
|
||||
&[&group, &image, &smask],
|
||||
);
|
||||
let d = open(&bytes);
|
||||
let (_, m) = the_mask(&d);
|
||||
let mut keys = vec![];
|
||||
m.art.walk(&mut |n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
keys.push(im.key.clone());
|
||||
}
|
||||
});
|
||||
let [key] = keys.as_slice() else { panic!("one image in the mask art: {keys:?}") };
|
||||
let px = image::load_from_memory(&d.images[key].bytes).unwrap().to_rgba8();
|
||||
[px.get_pixel(0, 0).0, px.get_pixel(1, 0).0]
|
||||
}
|
||||
|
||||
/// An alpha soft mask whose art is an image with its own alpha (some PDF writers rasterize masks
|
||||
/// so): the image is read white with that alpha, so the mask's luminance is the alpha, not the
|
||||
/// image's colour (ISO 32000-1 11.5.2: an alpha mask takes the group's alpha, not its colour).
|
||||
#[test]
|
||||
fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() {
|
||||
assert_eq!(mask_image_pixels("Alpha"), [[255, 255, 255, 255], [255, 255, 255, 0]]);
|
||||
}
|
||||
|
||||
/// A luminosity soft mask takes the group's colour (ISO 32000-1 11.5.3): its image stays black.
|
||||
#[test]
|
||||
fn a_luminosity_soft_mask_of_an_image_keeps_the_images_colour() {
|
||||
assert_eq!(mask_image_pixels("Luminosity"), [[0, 0, 0, 255], [0, 0, 0, 0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_luminosity_soft_mask_becomes_an_opacity_mask() {
|
||||
let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));
|
||||
@@ -544,3 +596,16 @@ fn glyphs_closed_up_by_half_an_em_stay_one_line() {
|
||||
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), "Tight");
|
||||
}
|
||||
|
||||
/// #1542: a clip only counts as covering the page when it does; a diamond whose bounding box is
|
||||
/// the page still clips, a page rectangle or a turned square around it doesn't make a group.
|
||||
#[test]
|
||||
fn clips_with_the_page_as_bounding_box_still_clip() {
|
||||
let clipped = |clip: &str| {
|
||||
let d = open(&one_page(&format!("q {clip} W n 1 0.5 0 rg 0 0 100 100 re f Q"), "", &[]));
|
||||
all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))
|
||||
};
|
||||
assert!(clipped("50 0 m 100 50 l 50 100 l 0 50 l h"), "diamond");
|
||||
assert!(!clipped("0 0 100 100 re"), "the page");
|
||||
assert!(!clipped("50 -60 m 160 50 l 50 160 l -60 50 l h"), "a turned square around the page");
|
||||
}
|
||||
@@ -131,6 +131,47 @@ fn a_stroke_written_as_stroked_glyph_outlines_is_the_types_stroke() {
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A `.ai` page moved right in the row, so its art past the artboard clears the page before it
|
||||
/// (#1528), still has the stroke drawn on its type's glyphs read as the type's stroke.
|
||||
#[test]
|
||||
fn a_stroke_on_the_type_of_a_moved_ai_page_is_still_the_types_stroke() {
|
||||
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
|
||||
let content = format!(
|
||||
"0 g -70 10 80 20 re f BT 1 0 0 rg /F1 24 Tf 10 50 Td (Hi) Tj ET 0 0 1 RG 0.5 w {}{}",
|
||||
outline('H', 10.0),
|
||||
outline('i', 10.0 + 0.722 * 24.0)
|
||||
);
|
||||
let pages = [("0 g 10 10 20 20 re f", String::new()), (content.as_str(), HELVETICA.to_string())].map(|(c, resources)| PdfPage {
|
||||
entries: ai.into(),
|
||||
resources,
|
||||
..PdfPage::new(100.0, 100.0, c)
|
||||
});
|
||||
let d = import(&vectorcraft_testkit::pdf::pdf(&pages, None)).unwrap();
|
||||
assert!(d.artboards[1].rect.x0 > d.artboards[0].rect.x1 + 36.0 + 1.0, "page 2 moved: {:?}", d.artboards);
|
||||
let art = d.layers[1].children().unwrap();
|
||||
assert_eq!(art.len(), 2, "the note and the type: {art:?}");
|
||||
assert!(matches!(art[1].appearance.items.as_slice(), [AppearanceItem::Stroke(_)]), "{:?}", art[1].appearance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyphs_each_followed_by_their_stroked_outline_stay_one_object() {
|
||||
// Type on a path is written glyph by glyph: each glyph, then its outline stroked in the same
|
||||
// colour (a shadow under the type).
|
||||
let glyph = |c: char, x: f64, stroke: &str| format!("BT 0 0 1 rg /F1 24 Tf {x} 50 Td ({c}) Tj ET {stroke} 0.5 w {}", outline(c, x));
|
||||
let i_at = 10.0 + 0.722 * 24.0;
|
||||
let d = import(&page(&(glyph('H', 10.0, "0 0 1 RG") + &glyph('i', i_at, "0 0 1 RG")))).unwrap();
|
||||
let art = d.layers[0].children().unwrap();
|
||||
assert_eq!(art.len(), 1, "one object: {art:?}");
|
||||
let NodeKind::Text(t) = &art[0].kind else { panic!("{:?}", art[0].kind) };
|
||||
assert_eq!(t.plain_text(), "Hi");
|
||||
let [AppearanceItem::Stroke(st)] = art[0].appearance.items.as_slice() else { panic!("{:?}", art[0].appearance) };
|
||||
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
|
||||
// A stroke of another colour drawn over each glyph in turn would also cover the next one's
|
||||
// neighbour as one object's stroke: the glyphs stay as the file draws them.
|
||||
let d = import(&page(&(glyph('H', 10.0, "1 1 1 RG") + &glyph('i', i_at, "1 1 1 RG")))).unwrap();
|
||||
assert_eq!(texts(&d).len(), 2);
|
||||
}
|
||||
|
||||
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3.
|
||||
fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec<u8> {
|
||||
crate::tests_live_text::text_pdf(lines, false)
|
||||
|
||||
@@ -158,6 +158,27 @@ fn art_of_neighbouring_artboards_off_a_page_is_left_to_its_own_page() {
|
||||
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 2);
|
||||
}
|
||||
|
||||
/// A `.ai` keeps its art past the artboard edge (#646): a note mostly left of artboard 2 (as notes
|
||||
/// for a design are put beside it) doesn't lie over artboard 1 in the row, where it would be taken
|
||||
/// for that page's art: page 2 moves right until its art clears page 1's.
|
||||
#[test]
|
||||
fn art_of_an_ai_page_reaching_left_does_not_lie_over_the_page_before() {
|
||||
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
|
||||
let page = |content: &str| PdfPage { entries: ai.into(), ..PdfPage::new(100.0, 100.0, content) };
|
||||
// Page 2's note runs from 70 points left of its box to 10 points into it.
|
||||
let pages = [page("0 g 10 10 20 20 re f"), page("1 1 1 rg 1 0 0 RG -70 10 80 60 re B")];
|
||||
let d = open(&pdf(&pages, None), ImportOptions::default());
|
||||
let (a, b) = (d.artboards[0].rect, d.artboards[1].rect);
|
||||
let note = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
|
||||
assert!(note.x0 >= a.x1 + 36.0, "the note clears artboard 1: {note:?} {a:?}");
|
||||
assert!((b.x0 - note.x0 - 70.0).abs() < 1.0, "and keeps its place by artboard 2: {note:?} {b:?}");
|
||||
assert_eq!((b.width(), b.height()), (100.0, 100.0));
|
||||
// A plain PDF clips its art to the page: its pages stay 36 points apart.
|
||||
let plain = [PdfPage::new(100.0, 100.0, "0 g 10 10 20 20 re f"), PdfPage::new(100.0, 100.0, "1 0 0 RG -70 10 80 60 re S")];
|
||||
let d = open(&pdf(&plain, None), ImportOptions::default());
|
||||
assert_eq!(d.artboards[1].rect.x0, d.artboards[0].rect.x1 + 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encrypted_pdf_opens_with_its_password_only() {
|
||||
let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret"));
|
||||
|
||||
@@ -424,3 +424,22 @@ fn glyphs_spaced_each_their_own_way_keep_their_place() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Evenly tracked type on a path comes back with its tracking: each glyph where it was along the
|
||||
/// curve, not closed up to the font's advances.
|
||||
#[test]
|
||||
fn evenly_tracked_type_on_a_path_keeps_its_length() {
|
||||
let mut arc = BezPath::new();
|
||||
arc.move_to((40.0, 200.0));
|
||||
arc.curve_to((100.0, 80.0), (260.0, 80.0), (320.0, 200.0));
|
||||
let mut t = TextObject::point(Point::ORIGIN, "ARCHING TYPE", CharStyle { tracking: 80.0, ..style(24.0) });
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None };
|
||||
let d = doc(vec![t]);
|
||||
let want = origins(&d);
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
||||
let got = origins(&back);
|
||||
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!(g.distance(*w) < 0.3, "{got:?}\nvs {want:?}");
|
||||
}
|
||||
}
|
||||
@@ -341,9 +341,11 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
|
||||
let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect();
|
||||
// k-means: move each entry to the mean of the cells nearest to it.
|
||||
for _ in 0..3 {
|
||||
// Centres move between passes, not within one: build the spatial index once per pass.
|
||||
let index = PaletteIndex::new(&palette, w);
|
||||
let mut sums = vec![([0f64; 3], 0f64); palette.len()];
|
||||
for c in &cells {
|
||||
let i = nearest(&palette, c.mean, w);
|
||||
let i = index.nearest(c.mean);
|
||||
for ch in 0..3 {
|
||||
sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64;
|
||||
}
|
||||
@@ -371,8 +373,137 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
|
||||
out
|
||||
}
|
||||
|
||||
fn nearest(palette: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
|
||||
palette.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
|
||||
/// An exact 3-D nearest-neighbour index for the weighted RGB distance used by palette export.
|
||||
///
|
||||
/// Balanced at the weighted widest axis. Every subtree carries tight channel bounds, so a
|
||||
/// branch-and-bound search skips it when even its nearest *possible* colour is farther than the
|
||||
/// best one found. Equal distances still choose the earliest palette index (as a linear scan did).
|
||||
/// For very small palettes a scan is cheaper than traversing the tree.
|
||||
struct PaletteNode {
|
||||
index: usize,
|
||||
lo: [f32; 3],
|
||||
hi: [f32; 3],
|
||||
left: Option<usize>,
|
||||
right: Option<usize>,
|
||||
}
|
||||
|
||||
struct PaletteIndex<'a> {
|
||||
colors: &'a [[f32; 3]],
|
||||
weights: [f32; 3],
|
||||
nodes: Vec<PaletteNode>,
|
||||
root: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'a> PaletteIndex<'a> {
|
||||
const SCAN_CUTOFF: usize = 12;
|
||||
|
||||
fn new(colors: &'a [[f32; 3]], weights: [f32; 3]) -> Self {
|
||||
let mut nodes = if colors.len() <= Self::SCAN_CUTOFF { Vec::new() } else { Vec::with_capacity(colors.len()) };
|
||||
let root = if colors.len() <= Self::SCAN_CUTOFF {
|
||||
None
|
||||
} else {
|
||||
let mut indices: Vec<usize> = (0..colors.len()).collect();
|
||||
Self::build(colors, weights, &mut indices, &mut nodes)
|
||||
};
|
||||
Self { colors, weights, nodes, root }
|
||||
}
|
||||
|
||||
// Median partitioning takes O(k log k) time and space for k palette entries.
|
||||
fn build(colors: &[[f32; 3]], w: [f32; 3], ids: &mut [usize], nodes: &mut Vec<PaletteNode>) -> Option<usize> {
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
|
||||
for &id in ids.iter() {
|
||||
let Some(c) = colors.get(id) else { continue };
|
||||
for axis in 0..3 {
|
||||
lo[axis] = lo[axis].min(c[axis]);
|
||||
hi[axis] = hi[axis].max(c[axis]);
|
||||
}
|
||||
}
|
||||
let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0);
|
||||
let mid = ids.len() / 2;
|
||||
ids.select_nth_unstable_by(mid, |&a, &b| {
|
||||
let ac = colors.get(a).map_or(0.0, |c| c[axis]);
|
||||
let bc = colors.get(b).map_or(0.0, |c| c[axis]);
|
||||
ac.total_cmp(&bc).then(a.cmp(&b))
|
||||
});
|
||||
let (before, rest) = ids.split_at_mut(mid);
|
||||
let (pivot, after) = rest.split_first_mut()?;
|
||||
let at = nodes.len();
|
||||
nodes.push(PaletteNode { index: *pivot, lo, hi, left: None, right: None });
|
||||
let left = Self::build(colors, w, before, nodes);
|
||||
let right = Self::build(colors, w, after, nodes);
|
||||
if let Some(n) = nodes.get_mut(at) {
|
||||
n.left = left;
|
||||
n.right = right;
|
||||
}
|
||||
Some(at)
|
||||
}
|
||||
|
||||
// Lower bound on *the same f32 weighted squared distance* as `dist`: avoid transforming
|
||||
// coordinates, which would change rounding and potentially break identical-palette ties.
|
||||
fn lower_bound(&self, id: usize, c: [f32; 3]) -> f32 {
|
||||
let Some(n) = self.nodes.get(id) else { return f32::INFINITY };
|
||||
(0..3)
|
||||
.map(|i| {
|
||||
let gap = if c[i] < n.lo[i] {
|
||||
n.lo[i] - c[i]
|
||||
} else if c[i] > n.hi[i] {
|
||||
c[i] - n.hi[i]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(self.weights[i] * gap).powi(2)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn search(&self, at: usize, c: [f32; 3], best: &mut (f32, usize)) {
|
||||
let Some(n) = self.nodes.get(at) else { return };
|
||||
let Some(&p) = self.colors.get(n.index) else { return };
|
||||
let d = dist(p, c, self.weights);
|
||||
if d < best.0 || (d == best.0 && n.index < best.1) {
|
||||
*best = (d, n.index);
|
||||
}
|
||||
// Search the more promising box first, tightening the bound for the second box.
|
||||
let left = n.left.map(|id| (id, self.lower_bound(id, c)));
|
||||
let right = n.right.map(|id| (id, self.lower_bound(id, c)));
|
||||
match (left, right) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (first, second) = if a.1 <= b.1 { (a, b) } else { (b, a) };
|
||||
if first.1 <= best.0 {
|
||||
self.search(first.0, c, best);
|
||||
}
|
||||
// `<=` is essential: an equal-distance colour may have an earlier index.
|
||||
if second.1 <= best.0 {
|
||||
self.search(second.0, c, best);
|
||||
}
|
||||
}
|
||||
(Some(a), None) | (None, Some(a)) => {
|
||||
if a.1 <= best.0 {
|
||||
self.search(a.0, c, best);
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest(&self, c: [f32; 3]) -> usize {
|
||||
if self.colors.len() <= Self::SCAN_CUTOFF {
|
||||
return self
|
||||
.colors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|a, b| dist(*a.1, c, self.weights).total_cmp(&dist(*b.1, c, self.weights)))
|
||||
.map_or(0, |(i, _)| i);
|
||||
}
|
||||
let mut best = (f32::INFINITY, 0);
|
||||
if let Some(root) = self.root {
|
||||
self.search(root, c, &mut best);
|
||||
}
|
||||
best.1
|
||||
}
|
||||
}
|
||||
|
||||
/// The 8×8 ordered-dither threshold at (x, y), in 0..1.
|
||||
@@ -398,19 +529,20 @@ fn noise(x: usize, y: usize) -> f32 {
|
||||
/// names (transparent) get index 0 and take no diffused error.
|
||||
fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec<u8> {
|
||||
let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect();
|
||||
let index = PaletteIndex::new(&pal, w);
|
||||
// Nearest entries by colour: exact colours without dithering, a 6-bit grid with it.
|
||||
let mut exact: HashMap<[u8; 3], u8> = HashMap::new();
|
||||
let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }];
|
||||
let mut lookup = |c: [f32; 3]| -> u8 {
|
||||
let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8);
|
||||
if grid.is_empty() {
|
||||
return *exact.entry(q).or_insert_with(|| nearest(&pal, c, w) as u8);
|
||||
return *exact.entry(q).or_insert_with(|| index.nearest(c) as u8);
|
||||
}
|
||||
let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2;
|
||||
match grid.get(key) {
|
||||
Some(&v) if v != u16::MAX => v as u8,
|
||||
_ => {
|
||||
let i = nearest(&pal, c, w) as u8;
|
||||
let i = index.nearest(c) as u8;
|
||||
if let Some(g) = grid.get_mut(key) {
|
||||
*g = i as u16;
|
||||
}
|
||||
@@ -466,3 +598,83 @@ fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dithe
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nearest_tests {
|
||||
use super::*;
|
||||
|
||||
// The original definition, retained only as a parity oracle for the spatial index.
|
||||
fn linear(colors: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
|
||||
colors.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
|
||||
}
|
||||
|
||||
fn next(seed: &mut u32) -> u32 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 17;
|
||||
*seed ^= *seed << 5;
|
||||
*seed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_nearest_matches_linear_for_palette_sizes_and_weights() {
|
||||
let mut seed = 0x2ab4_1017;
|
||||
for w in [[1.0; 3], [0.55, 0.77, 0.34], [0.1, 1.0, 0.01]] {
|
||||
for size in [0, 1, 2, 8, 12, 13, 16, 17, 32, 64, 128, 256] {
|
||||
// Include duplicated colours, close k-means-style floating-point centres,
|
||||
// and a wide colour gamut. Query points are fractional, as with dithering.
|
||||
let colors: Vec<[f32; 3]> =
|
||||
(0..size)
|
||||
.map(|i| {
|
||||
if i % 11 == 0 {
|
||||
[127.0, 127.0, 127.0]
|
||||
} else {
|
||||
[0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let index = PaletteIndex::new(&colors, w);
|
||||
for _ in 0..500 {
|
||||
let c = [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0));
|
||||
assert_eq!(index.nearest(c), linear(&colors, c, w), "size {size}, weights {w:?}, query {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_distance_always_chooses_first_palette_entry() {
|
||||
// Enough entries to exercise the tree, even when they share one bounding box.
|
||||
let mut colors = vec![[128.0, 128.0, 128.0]; 256];
|
||||
colors[0] = [0.0, 128.0, 128.0];
|
||||
colors[255] = [2.0, 128.0, 128.0];
|
||||
let index = PaletteIndex::new(&colors, [1.0; 3]);
|
||||
assert_eq!(index.nearest([1.0, 128.0, 128.0]), 0, "equal distance to indices 0 and 255");
|
||||
assert_eq!(index.nearest([128.0; 3]), 1, "equal among 254 identical colours");
|
||||
let colors = vec![[10.0, 10.0, 10.0]; 256];
|
||||
assert_eq!(PaletteIndex::new(&colors, [0.55, 0.77, 0.34]).nearest([10.0; 3]), 0);
|
||||
}
|
||||
|
||||
/// Opt-in microbenchmark: compares the same nearest-neighbour queries, without image IO.
|
||||
#[test]
|
||||
#[ignore = "run with cargo test -p vectorcraft-render nearest_palette_benchmark --release -- --ignored --nocapture"]
|
||||
fn nearest_palette_benchmark() {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut seed = 0x87bb_7641;
|
||||
let colors: Vec<[f32; 3]> = (0..256).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 256) as f32)).collect();
|
||||
let queries: Vec<[f32; 3]> = (0..100_000).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))).collect();
|
||||
let w = [0.55, 0.77, 0.34];
|
||||
let start = Instant::now();
|
||||
let baseline: Vec<_> = queries.iter().map(|&c| linear(&colors, c, w)).collect();
|
||||
let old = start.elapsed();
|
||||
let start = Instant::now();
|
||||
let index = PaletteIndex::new(&colors, w);
|
||||
let improved: Vec<_> = queries.iter().map(|&c| index.nearest(c)).collect();
|
||||
let new = start.elapsed();
|
||||
assert_eq!(baseline, improved);
|
||||
eprintln!(
|
||||
"100k nearest-colour queries, 256 entries: linear {old:?}, spatial index {new:?}, speedup {:.2}x",
|
||||
old.as_secs_f64() / new.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -274,6 +274,9 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>
|
||||
// On whole pixels at full size the cheapest sampler is exact.
|
||||
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
// The raster maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern,
|
||||
// radial or freeform gradient) would resample it (#1536).
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
@@ -617,6 +620,7 @@ impl Renderer {
|
||||
ctx.set_transform(Affine::translate((x, y)));
|
||||
ctx.set_blend_mode(blend_mode(mode));
|
||||
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
|
||||
|
||||
@@ -16,6 +16,7 @@ mod ink;
|
||||
mod live;
|
||||
mod lru;
|
||||
mod paint;
|
||||
pub use paint::decode_failures;
|
||||
mod pattern;
|
||||
pub mod placed_document;
|
||||
pub mod proof;
|
||||
@@ -95,6 +96,9 @@ pub struct RenderOptions {
|
||||
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
|
||||
/// Color), RGB.
|
||||
pub tile_edge: [u8; 3],
|
||||
/// Colour and style of the paths made into guides (Preferences › Guides & Grid, as the ruler
|
||||
/// guides the UI draws): RGB, and whether they are dotted.
|
||||
pub guide: ([u8; 3], bool),
|
||||
/// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's
|
||||
/// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent).
|
||||
pub mask_view: Option<NodeId>,
|
||||
@@ -200,6 +204,7 @@ impl Default for RenderOptions {
|
||||
trim: false,
|
||||
skip_templates: false,
|
||||
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
|
||||
guide: ([0x4a, 0xd8, 0xff], false),
|
||||
mask_view: None,
|
||||
highlight_substitutions: false,
|
||||
anti_alias: AntiAlias::Art,
|
||||
@@ -1157,7 +1162,17 @@ impl Renderer {
|
||||
NodeKind::Path { path, rule, guide, .. } => {
|
||||
let bp = path.to_bezpath();
|
||||
if *guide {
|
||||
self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]);
|
||||
let ([r, g, b], dots) = f.opts.guide;
|
||||
if dots {
|
||||
let mut screen = bp.clone();
|
||||
screen.apply_affine(f.view);
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_stroke(kurbo::Stroke::new(1.5).with_dashes(0.0, [1.5, 2.5]));
|
||||
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
|
||||
ctx.stroke_path(&screen);
|
||||
} else {
|
||||
self.hairline(ctx, f, &bp, [r, g, b, 255]);
|
||||
}
|
||||
} else {
|
||||
self.draw_shape(ctx, f, n, &bp, *rule);
|
||||
}
|
||||
|
||||
@@ -52,14 +52,53 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap.
|
||||
/// The most memory decoding one image may take: print rasters run to gigabytes (a 300 ppi roll,
|
||||
/// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it.
|
||||
const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
|
||||
/// Images that couldn't be decoded (and so drew nothing), counted for exports to report.
|
||||
static DECODE_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// How many images have failed to decode so far (process-wide): an export compares the count
|
||||
/// before and after to say it left images out.
|
||||
pub fn decode_failures() -> u64 {
|
||||
DECODE_FAILURES.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// `bytes` decoded within [`MAX_DECODE_ALLOC`]; a failure is counted in [`decode_failures`].
|
||||
fn decode(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let img = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|mut r| {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
r.limits(limits);
|
||||
r.decode().ok()
|
||||
});
|
||||
if img.is_none() {
|
||||
DECODE_FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// `img` shrunk, keeping its proportions, to fit a pixmap's sides (at most `u16::MAX` pixels).
|
||||
fn fit_pixmap(img: image::DynamicImage) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let max = u16::MAX as u32;
|
||||
if w <= max && h <= max {
|
||||
return img;
|
||||
}
|
||||
let s = max as f64 / w.max(h) as f64;
|
||||
let (nw, nh) = (((w as f64 * s).round() as u32).clamp(1, max), ((h as f64 * s).round() as u32).clamp(1, max));
|
||||
img.resize_exact(nw, nh, image::imageops::FilterType::Triangle)
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (shrunk to fit a pixmap's sides).
|
||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
|
||||
pixmap_of(image::load_from_memory(bytes).ok()?.to_rgba8())
|
||||
pixmap_of(fit_pixmap(decode(bytes)?).to_rgba8())
|
||||
}
|
||||
|
||||
/// [`decode_pixmap`] shrunk to `w`×`h` pixels when the image is larger (never enlarged).
|
||||
pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option<vello_cpu::Pixmap> {
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let img = decode(bytes)?;
|
||||
let img = if img.width() > w as u32 || img.height() > h as u32 {
|
||||
img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
|
||||
+174
-10
@@ -1,12 +1,12 @@
|
||||
//! Pattern fills and the pattern-editing preview.
|
||||
//!
|
||||
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
|
||||
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
|
||||
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
|
||||
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
|
||||
//! document. Tiles are cached per pattern name (and ink plane, see [`crate::ink`]) and invalidated
|
||||
//! when the definition changes (art `Arc` identity, tile, tile type, overlap) — the pattern's
|
||||
//! "revision".
|
||||
//! ([`PatternDef::period`]) is rasterized at the device scale it is painted at, rounded up to a
|
||||
//! power of two and capped ([`raster_scale`]), and used as an image paint with `Extend::Repeat`,
|
||||
//! 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".
|
||||
//!
|
||||
//! 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.
|
||||
@@ -28,10 +28,13 @@ use crate::{Frame, RenderOptions, Rendered, Renderer};
|
||||
/// Largest super-tile raster side (pixels).
|
||||
const MAX_TILE_PX: f64 = 2048.0;
|
||||
|
||||
/// What a tile is kept under: the pattern's name and the bits of its raster scale
|
||||
/// ([`raster_scale`]).
|
||||
type TileKey = (String, u64);
|
||||
|
||||
struct TileEntry {
|
||||
art: Vec<Arc<Node>>,
|
||||
params: (Rect, TileType, Overlap),
|
||||
scale: f64,
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
pixmap: Arc<Pixmap>,
|
||||
@@ -39,7 +42,7 @@ struct TileEntry {
|
||||
|
||||
struct PatternCache {
|
||||
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
|
||||
tiles: [FrameCache<String, TileEntry>; 4],
|
||||
tiles: [FrameCache<TileKey, TileEntry>; 4],
|
||||
renderer: Option<Box<Renderer>>,
|
||||
depth: u32,
|
||||
}
|
||||
@@ -155,11 +158,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let (pw, ph) = def.period();
|
||||
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 hit = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink as usize];
|
||||
let t = tiles.tick();
|
||||
tiles.get(name, t).filter(|e| e.scale == scale && 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)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
|
||||
});
|
||||
let (pm, sx, sy) = match hit {
|
||||
Some(h) => h,
|
||||
@@ -176,7 +180,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
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(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||
tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t);
|
||||
});
|
||||
(pm, sx, sy)
|
||||
}
|
||||
@@ -274,6 +278,58 @@ mod tests {
|
||||
p[0] > 200 && p[1] > 200 && p[2] > 200
|
||||
}
|
||||
|
||||
/// #1536: a raster effect drawn after a pattern fill is the same as without the pattern (the
|
||||
/// pattern's paint transform must not resample the effect's raster).
|
||||
#[test]
|
||||
fn effects_after_a_pattern_fill_keep_their_place_and_size() {
|
||||
let fx = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
|
||||
for effect in [
|
||||
fx("blur.gaussian", serde_json::json!({"radius": 4.0})),
|
||||
fx("stylize.dropShadow", serde_json::json!({"x": 6, "y": 6, "blur": 3, "opacity": 100})),
|
||||
] {
|
||||
let make = |with_pattern: bool| {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let l = d.layers[0].id;
|
||||
let filled = d.children(Some(l)).unwrap()[0].id;
|
||||
d.remove(filled).unwrap();
|
||||
if with_pattern {
|
||||
let id = d.alloc_id();
|
||||
let n = Node::path(
|
||||
id,
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)),
|
||||
Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0),
|
||||
);
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(
|
||||
id,
|
||||
shapes::ellipse(Rect::new(120.0, 110.0, 180.0, 170.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(effect.clone());
|
||||
let at = d.children(Some(l)).map_or(0, Vec::len);
|
||||
d.insert(Some(l), at, n).unwrap();
|
||||
d
|
||||
};
|
||||
// Supersampled, as raster exports draw.
|
||||
let one = |d: &Document| {
|
||||
let mut r = Renderer::new();
|
||||
r.render(
|
||||
d,
|
||||
200,
|
||||
200,
|
||||
Affine::IDENTITY,
|
||||
&RenderOptions { background: Some([255, 255, 255, 255]), supersample: true, ..Default::default() },
|
||||
)
|
||||
};
|
||||
let (with, without) = (one(&make(true)), one(&make(false)));
|
||||
for (x, y) in [(150, 140), (125, 140), (176, 160), (150, 172), (184, 176)] {
|
||||
assert_eq!(with.pixel(x, y), without.pixel(x, y), "{} at ({x}, {y})", effect.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_is_periodic() {
|
||||
let d = doc_with_pattern(TileType::Grid);
|
||||
@@ -327,6 +383,114 @@ mod tests {
|
||||
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
|
||||
}
|
||||
|
||||
/// `doc_with_pattern(TileType::Grid)` with a 40×40 square on top of its rectangle, filled with
|
||||
/// the same pattern scaled by `s`, as Transform Pattern Tiles leaves it.
|
||||
fn doc_with_two_scales(s: f64) -> Document {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
let square = shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0));
|
||||
let mut n = Node::path(d.alloc_id(), square, Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
|
||||
vectorcraft_doc::pattern::transform_pattern_paints(&mut n, Affine::scale(s));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 1, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `d` drawn by `r` at `scale`.
|
||||
fn frame(r: &mut Renderer, d: &Document, scale: f64) -> Rendered {
|
||||
let px = (200.0 * scale) as u32;
|
||||
r.render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
}
|
||||
|
||||
/// The tiles rasterized on this thread so far: the tile renderer counts a frame for each.
|
||||
fn tiles_rasterized() -> u64 {
|
||||
CACHE.with(|c| c.borrow().renderer.as_ref().map_or(0, |r| r.stamp))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_at_two_pattern_scales_keep_their_tiles_between_frames() {
|
||||
// At 100% the rectangle's tile is rasterized at scale 1, and the square's tile is
|
||||
// rasterized at 0.25, or at 2 when its tiles are scaled to 101%.
|
||||
for s in [0.25, 1.01] {
|
||||
let d = doc_with_two_scales(s);
|
||||
let mut r = Renderer::new();
|
||||
let start = tiles_rasterized();
|
||||
let first = frame(&mut r, &d, 1.0).pixels;
|
||||
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: a tile for each object");
|
||||
for _ in 0..3 {
|
||||
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
|
||||
}
|
||||
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: later frames draw both objects from their tiles");
|
||||
// After a frame at 200%, the next frame at 100% draws from the tiles made at 100%.
|
||||
frame(&mut r, &d, 2.0);
|
||||
let zoomed = tiles_rasterized();
|
||||
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
|
||||
assert_eq!(tiles_rasterized(), zoomed, "tiles scaled by {s}: back at 100%");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_tiles_draw_what_a_new_renderer_draws() {
|
||||
// A new renderer on a thread of its own starts with no tiles.
|
||||
let fresh = |d: &Document, zoom: f64| {
|
||||
let d = d.clone();
|
||||
std::thread::spawn(move || frame(&mut Renderer::new(), &d, zoom).pixels).join().unwrap()
|
||||
};
|
||||
let mut d = doc_with_two_scales(0.25);
|
||||
let mut r = Renderer::new();
|
||||
// Pixel (130, 130) is red in the square's tiles and clear in the rectangle's, (133, 133) is
|
||||
// clear in both, and (103, 103), outside the square, is red in the rectangle's.
|
||||
let first = frame(&mut r, &d, 1.0);
|
||||
assert!(is_red(first.pixel(130, 130)) && is_white(first.pixel(133, 133)) && is_red(first.pixel(103, 103)));
|
||||
for zoom in [1.0, 2.0, 1.0, 0.5, 2.0, 1.0] {
|
||||
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "zoom {zoom}");
|
||||
}
|
||||
// Change the tile type as Pattern Options does.
|
||||
d.patterns[0].tile_type = TileType::BrickByRow { offset: 0.5 };
|
||||
for zoom in [2.0, 1.0] {
|
||||
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "brick, zoom {zoom}");
|
||||
}
|
||||
// Replace the art as Done Editing Pattern does.
|
||||
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
|
||||
d.patterns[0].art = vec![Arc::new(Node::path(NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), blue))];
|
||||
for zoom in [1.0, 2.0] {
|
||||
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "new art, zoom {zoom}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_tiles_stay_within_the_budget() {
|
||||
// The rectangle's tile is 20 pixels a side per 100% of zoom, and the square's tile is 5
|
||||
// pixels a side per 100% of zoom. The budget holds the rectangle's tile at 400% and both
|
||||
// tiles at 200%; the tiles drawn from 100% to 400% need more.
|
||||
let d = doc_with_two_scales(0.25);
|
||||
let tile = |side: usize| size_of::<TileEntry>() + side * side * 4;
|
||||
let budget = tile(80) + tile(40) + tile(10);
|
||||
CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX));
|
||||
let kept = || {
|
||||
CACHE.with(|c| {
|
||||
let c = c.borrow();
|
||||
let tiles = &c.tiles[Ink::Display as usize];
|
||||
(tiles.len(), tiles.bytes())
|
||||
})
|
||||
};
|
||||
let mut r = Renderer::new();
|
||||
for zoom in [1.0, 2.0, 4.0] {
|
||||
frame(&mut r, &d, zoom);
|
||||
let drawn = tiles_rasterized();
|
||||
frame(&mut r, &d, zoom);
|
||||
assert_eq!(tiles_rasterized(), drawn, "zoom {zoom}: the next frame draws from the tiles just made");
|
||||
let (len, bytes) = kept();
|
||||
assert!(len >= 2 && bytes <= budget, "zoom {zoom}: {len} tiles, {bytes} bytes");
|
||||
}
|
||||
// Tiles were rasterized at five scales (0.25, 0.5, 1, 2 and 4); the cache dropped the least
|
||||
// recently used tiles first.
|
||||
assert!(kept().0 < 5, "{:?}", kept());
|
||||
let before = tiles_rasterized();
|
||||
frame(&mut r, &d, 1.0);
|
||||
assert!(tiles_rasterized() > before, "a tile of 100% was dropped");
|
||||
assert!(kept().1 <= budget, "{:?}", kept());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_pattern_is_grey() {
|
||||
let mut d = doc_with_pattern(TileType::Grid);
|
||||
|
||||
@@ -467,3 +467,39 @@ fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
|
||||
assert_eq!(supersample_factor(5000, 5000), None);
|
||||
assert_eq!(supersample_factor(40_000, 10), None);
|
||||
}
|
||||
|
||||
/// #1613: a zero-length subpath (`m` then `L` to the same point, as `.ai` files write dots) is a
|
||||
/// dot as wide as the stroke: a disc under a round cap, a square under a projecting one, nothing
|
||||
/// under a butt cap (PDF 32000-1 8.5.3.2, SVG 2 "Path data" zero-length segments).
|
||||
#[test]
|
||||
fn zero_length_subpaths_draw_dots_with_their_cap() {
|
||||
let dots = |cap: vectorcraft_doc::LineCap| {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut bp = BezPath::new();
|
||||
for at in [(30.0, 50.0), (70.0, 50.0)] {
|
||||
bp.move_to(at);
|
||||
bp.line_to(at);
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0));
|
||||
n.appearance.stroke_mut().unwrap().cap = cap;
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
render(&d)
|
||||
};
|
||||
let (black, white) = ([0, 0, 0, 255], [255, 255, 255, 255]);
|
||||
let round = dots(vectorcraft_doc::LineCap::Round);
|
||||
for x in [30, 70] {
|
||||
assert_eq!(round.pixel(x, 50), black, "round dot at {x}");
|
||||
assert_eq!(round.pixel(x + 2, 52), black, "inside the round dot at {x}");
|
||||
assert_eq!(round.pixel(x + 4, 54), white, "the round dot's corner at {x}");
|
||||
}
|
||||
let square = dots(vectorcraft_doc::LineCap::Square);
|
||||
for x in [30, 70] {
|
||||
assert_eq!(square.pixel(x, 50), black, "square dot at {x}");
|
||||
assert_eq!(square.pixel(x + 4, 54), black, "the square dot's corner at {x}");
|
||||
assert_eq!(square.pixel(x + 6, 50), white, "past the square dot at {x}");
|
||||
}
|
||||
let butt = dots(vectorcraft_doc::LineCap::Butt);
|
||||
assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot");
|
||||
}
|
||||
Loaded 100 of 207 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user