mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-18 05:07:02 -04:00
Compare commits
318
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87a5b4cc83 | ||
|
|
d7a61f1c50 | ||
|
|
95e98d18de | ||
|
|
f4668bfb2b | ||
|
|
590f91574d | ||
|
|
facab88bcc | ||
|
|
4ffaeb1895 | ||
|
|
99b3917aaa | ||
|
|
2ef544cab7 | ||
|
|
67673faea7 | ||
|
|
ec3a1d2a82 | ||
|
|
a96a6800c9 | ||
|
|
2ca923b43a | ||
|
|
ef666b4bfd | ||
|
|
e26393c7d8 | ||
|
|
ba69d744ae | ||
|
|
879fb63248 | ||
|
|
5b7411e9d1 | ||
|
|
b5715f7509 | ||
|
|
66cfc1dca6 | ||
|
|
74cf04fa3b | ||
|
|
fda352cf7b | ||
|
|
98fc8146dc | ||
|
|
3988aafc1a | ||
|
|
2433c44915 | ||
|
|
dfde775213 | ||
|
|
2fa0e979f3 | ||
|
|
eba745e354 | ||
|
|
64fda576b2 | ||
|
|
07bf1ddf8c | ||
|
|
cc5fba7577 | ||
|
|
95427b71bf | ||
|
|
c9bebb92d6 | ||
|
|
eaa21d42d5 | ||
|
|
d5cfb27989 | ||
|
|
a7fded4584 | ||
|
|
3e09a469de | ||
|
|
079cd4983e | ||
|
|
1651e7bea7 | ||
|
|
e01fe134e9 | ||
|
|
f5d61f6f27 | ||
|
|
7da58e35cc | ||
|
|
a03583fc73 | ||
|
|
598f1de3cc | ||
|
|
3fe8aa7db9 | ||
|
|
015bab68a1 | ||
|
|
23dd9a1f2a | ||
|
|
20590e778c | ||
|
|
04e784a04f | ||
|
|
1de20a2972 | ||
|
|
35d70f5531 | ||
|
|
2ccb147403 | ||
|
|
a12f5b0f20 | ||
|
|
efb49169a3 | ||
|
|
6b13dd776b | ||
|
|
26af00d108 | ||
|
|
6e29afe648 | ||
|
|
919970a467 | ||
|
|
7f858a7e59 | ||
|
|
dc48ce6c74 | ||
|
|
ceddd8185f | ||
|
|
aeb9314e2c | ||
|
|
85fce6852e | ||
|
|
6a2bb03ebf | ||
|
|
a209996da2 | ||
|
|
1c4ead3703 | ||
|
|
dd466d79e4 | ||
|
|
8fbd515d6c | ||
|
|
c44fb242aa | ||
|
|
e5945c6795 | ||
|
|
81574b1648 | ||
|
|
f7382b2844 | ||
|
|
039a467dcb | ||
|
|
1f304f6a23 | ||
|
|
45c2b0b363 | ||
|
|
85afe7caaf | ||
|
|
92e33b1579 | ||
|
|
fa9a52bd71 | ||
|
|
5f70f96a66 | ||
|
|
10ae4cc5a8 | ||
|
|
eeeb9645c1 | ||
|
|
e1a6e373d5 | ||
|
|
286a1fb8b3 | ||
|
|
949473f1e3 | ||
|
|
f999688b41 | ||
|
|
b5164825d6 | ||
|
|
56df1a1663 | ||
|
|
6a42b3bf10 | ||
|
|
5b85ca6358 | ||
|
|
e2b4cef111 | ||
|
|
f92c622e97 | ||
|
|
43356a1cfb | ||
|
|
b55d277b49 | ||
|
|
2a5dfa6a94 | ||
|
|
6190525482 | ||
|
|
f7de3d6a01 | ||
|
|
daaeb45565 | ||
|
|
af555713d5 | ||
|
|
c1c677bc76 | ||
|
|
a86cb76d52 | ||
|
|
2efd084b97 | ||
|
|
665373b699 | ||
|
|
4bf8b3314a | ||
|
|
0bb793c079 | ||
|
|
68343ab66a | ||
|
|
4e620ced97 | ||
|
|
15f55f5569 | ||
|
|
fe3e8f65a5 | ||
|
|
5ad04faff3 | ||
|
|
80b9753866 | ||
|
|
4712fb2c2f | ||
|
|
1e06c82800 | ||
|
|
17a602d1dc | ||
|
|
a2e2eba2e2 | ||
|
|
0594ffe30f | ||
|
|
abb7c1351b | ||
|
|
c9f0ed14f0 | ||
|
|
8c10185ef9 | ||
|
|
b9ff56e403 | ||
|
|
f38f6fafc3 | ||
|
|
88567ee46c | ||
|
|
04b0be2c9f | ||
|
|
f5511df65f | ||
|
|
2879c206a8 | ||
|
|
e78116e0a2 | ||
|
|
351612c978 | ||
|
|
7930a0456b | ||
|
|
fdac986bb4 | ||
|
|
fcdddadeb8 | ||
|
|
97cd86d461 | ||
|
|
47b1fa52cb | ||
|
|
21dc3bf565 | ||
|
|
6ecf568c5d | ||
|
|
7d677eaa13 | ||
|
|
20a40f5b1b | ||
|
|
e3a67119c1 | ||
|
|
003ec8f373 | ||
|
|
1fd7ab495a | ||
|
|
e7e149a8bd | ||
|
|
4417c4feb7 | ||
|
|
b0e78cf3d8 | ||
|
|
1401051603 | ||
|
|
14a3740e51 | ||
|
|
30c5bd3589 | ||
|
|
e09038041f | ||
|
|
59f63e9161 | ||
|
|
336d860ed3 | ||
|
|
7bc9fd7220 | ||
|
|
cec7d23d96 | ||
|
|
c18069e6ed | ||
|
|
fb8578ddf0 | ||
|
|
546b8846b6 | ||
|
|
24b6486ebd | ||
|
|
d22ccebc9c | ||
|
|
fa4e42762b | ||
|
|
2045fe3de1 | ||
|
|
eb672e8f42 | ||
|
|
82e2c9506b | ||
|
|
fff1acac06 | ||
|
|
8186f9e2e4 | ||
|
|
5f0d809047 | ||
|
|
f19a71367d | ||
|
|
49686460aa | ||
|
|
8ecb22851d | ||
|
|
2aa7412e3a | ||
|
|
18d01ae6ff | ||
|
|
3c5034b3dd | ||
|
|
1168b2373f | ||
|
|
03e867df02 | ||
|
|
0c0267acbf | ||
|
|
3e9c590ee4 | ||
|
|
06f0700155 | ||
|
|
3981e0bd03 | ||
|
|
37f578ff34 | ||
|
|
8f87ad53dd | ||
|
|
67b47c7262 | ||
|
|
1a9509b776 | ||
|
|
5669e02331 | ||
|
|
4285ccdda3 | ||
|
|
c5405479b9 | ||
|
|
df16d21e84 | ||
|
|
133fd73feb | ||
|
|
8ee5f96da5 | ||
|
|
e74b09c86d | ||
|
|
a631a0df6c | ||
|
|
d18135bfcd | ||
|
|
3adf5141e7 | ||
|
|
1651dadf41 | ||
|
|
e4ad256d7d | ||
|
|
c692962a22 | ||
|
|
276eeacae6 | ||
|
|
3eed742639 | ||
|
|
5b1e18f2fc | ||
|
|
baec72c0fe | ||
|
|
a12af589aa | ||
|
|
d1a6e39b7e | ||
|
|
838d81157e | ||
|
|
009384b2e7 | ||
|
|
8776f6c896 | ||
|
|
150467c7f9 | ||
|
|
08afdd101d | ||
|
|
93cdb4c4e3 | ||
|
|
c01eeff7ed | ||
|
|
9ca534ba2e | ||
|
|
a329e499ce | ||
|
|
2217ad5009 | ||
|
|
9745847617 | ||
|
|
403bc4a804 | ||
|
|
7b28595c11 | ||
|
|
0e18cf2a08 | ||
|
|
fe559785c5 | ||
|
|
e91140e743 | ||
|
|
bbd015651d | ||
|
|
b11c9a9c57 | ||
|
|
2f01a1df92 | ||
|
|
0332936393 | ||
|
|
06ad0becf1 | ||
|
|
800db5b29d | ||
|
|
84ccee71ef | ||
|
|
b2586232ae | ||
|
|
72a6efc814 | ||
|
|
53b75a0acb | ||
|
|
74ee51313d | ||
|
|
6daf74fb6d | ||
|
|
b9309f7985 | ||
|
|
f9759afa30 | ||
|
|
be553fa3cf | ||
|
|
b959cd90da | ||
|
|
8bcebf0e48 | ||
|
|
a6bac3298b | ||
|
|
0b6f3285ab | ||
|
|
d949c3d8b7 | ||
|
|
62fd793603 | ||
|
|
f526671534 | ||
|
|
92a801a04f | ||
|
|
53100a458f | ||
|
|
0ed83870a4 | ||
|
|
e7b78e7150 | ||
|
|
77f39084de | ||
|
|
ab1c386ca4 | ||
|
|
0887872228 | ||
|
|
abe714bbae | ||
|
|
86e24874b1 | ||
|
|
e0525e24ff | ||
|
|
6603256286 | ||
|
|
0131a75fd8 | ||
|
|
e0856dd574 | ||
|
|
2a8efd7aee | ||
|
|
c0c497f4c3 | ||
|
|
14d12ac5bd | ||
|
|
4cd0757512 | ||
|
|
86bd11a271 | ||
|
|
b7ffd4a91e | ||
|
|
5e88acde4d | ||
|
|
582d527612 | ||
|
|
82047aca3e | ||
|
|
9cf044b9f4 | ||
|
|
307bb3b3cc | ||
|
|
c10677f385 | ||
|
|
76f8f68849 | ||
|
|
3e65dd7cf3 | ||
|
|
dc64a0c8e1 | ||
|
|
d15eae5fa5 | ||
|
|
1d72a6f895 | ||
|
|
f8af51a341 | ||
|
|
caabf08f3e | ||
|
|
2f24495ffe | ||
|
|
989768207f | ||
|
|
6bbf30e52a | ||
|
|
1946ffca9c | ||
|
|
3f81c70ade | ||
|
|
f3072fc748 | ||
|
|
0e26b258eb | ||
|
|
877aac130f | ||
|
|
d7026367f7 | ||
|
|
e37fbe3849 | ||
|
|
9739eeb333 | ||
|
|
77f7f02acc | ||
|
|
025ce255ae | ||
|
|
8e69146160 | ||
|
|
7add248dc3 | ||
|
|
cb968353a5 | ||
|
|
08dc614938 | ||
|
|
a070d83be3 | ||
|
|
63b95c8dd3 | ||
|
|
b275eb04d0 | ||
|
|
4650ccbe26 | ||
|
|
4f1e4979fc | ||
|
|
3895694a9b | ||
|
|
e6b58f5142 | ||
|
|
afa1c992bf | ||
|
|
8548cf272d | ||
|
|
d890ab337d | ||
|
|
c1f543dd75 | ||
|
|
dbef720363 | ||
|
|
67fbb3c18d | ||
|
|
a2aa0fc303 | ||
|
|
99220d50e1 | ||
|
|
770213b295 | ||
|
|
f72d5060f2 | ||
|
|
3c2fa5917d | ||
|
|
7ddc8d9c8a | ||
|
|
85baa19884 | ||
|
|
f4000408a2 | ||
|
|
2cdf8465b9 | ||
|
|
8f42ac1892 | ||
|
|
ab2f745cd3 | ||
|
|
7a97db06b7 | ||
|
|
17a1c9835b | ||
|
|
0990c7bbd3 | ||
|
|
41d79128f5 | ||
|
|
eff0baad56 | ||
|
|
f7d1c354fd | ||
|
|
66374d0835 | ||
|
|
e5745c4fc1 | ||
|
|
4eba49ff48 | ||
|
|
67a4b8fd1d | ||
|
|
fa46998006 |
No files matched your search
Generated
+5
@@ -4617,6 +4617,7 @@ dependencies = [
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4656,6 +4657,7 @@ dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"roxmltree 0.21.1",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4686,7 +4688,9 @@ dependencies = [
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"ruzstd",
|
||||
@@ -4881,6 +4885,7 @@ dependencies = [
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
|
||||
@@ -79,6 +79,9 @@ 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.1", default-features = false, features = ["std"] }
|
||||
# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through
|
||||
# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version.
|
||||
hayro-ccitt = "0.3"
|
||||
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
|
||||
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
|
||||
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
|
||||
|
||||
@@ -194,8 +194,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to *
|
||||
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
|
||||
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
|
||||
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
|
||||
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same
|
||||
preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is
|
||||
attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see
|
||||
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
@@ -245,7 +247,7 @@ The scores are self-assessed from our code against Illustrator's public document
|
||||
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
- generative AI features.
|
||||
|
||||
+9
-5
@@ -9,7 +9,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 75 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (about 40 community PRs of 2026-10-18: Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 44/50, Photoshop effects 30/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
|
||||
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 6–10 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 23–39 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: 28 filters and the Effect Gallery, 23–39 h.
|
||||
5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
|
||||
## Progress log
|
||||
@@ -134,7 +134,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-17 | About 75 community PRs landed in three batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-18 | About 40 community PRs landed in one batch through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
|
||||
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
@@ -179,6 +180,9 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
|
||||
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
|
||||
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
|
||||
|
||||
@@ -159,11 +159,12 @@ fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless session that remembers what the user keeps between runs: the PDF presets saved and
|
||||
/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop
|
||||
/// A headless session that remembers what the user keeps between runs: the PDF presets saved, the
|
||||
/// ICC profiles loaded and the plug-ins installed (#2168, #2174, #2541), in the folders next to the preferences that the desktop
|
||||
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
|
||||
fn with_stores(mut h: Headless) -> Headless {
|
||||
prefs_dir::attach_profile_store(&mut h.session);
|
||||
prefs_dir::attach_plugin_store(&mut h.session);
|
||||
prefs_dir::attach_pdf_preset_store(&mut h.session);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -383,7 +383,10 @@ mod tests {
|
||||
});
|
||||
let mut reader = BufReader::new(client.try_clone().unwrap());
|
||||
for id in [1, 2] {
|
||||
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).unwrap();
|
||||
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
|
||||
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
|
||||
// between two of them closes the connection.
|
||||
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
|
||||
|
||||
@@ -529,6 +529,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
no_cursor_theme: wayland && cursor_theme::theme_missing(),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
|
||||
// Background Save and Export write from a worker thread.
|
||||
write_shared: Some(std::sync::Arc::new(write_file)),
|
||||
// Native saves stream into the file as they are encoded.
|
||||
@@ -787,14 +788,19 @@ fn main() -> std::process::ExitCode {
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
|
||||
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
|
||||
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
|
||||
#[cfg(target_os = "linux")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
|
||||
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
|
||||
if xwayland::x11_libraries_found() {
|
||||
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
if pref == Some("x11WithPen") {
|
||||
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
} else {
|
||||
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
|
||||
}
|
||||
use winit::platform::x11::EventLoopBuilderExtX11 as _;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_x11();
|
||||
@@ -857,6 +863,7 @@ fn main() -> std::process::ExitCode {
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
prefs_dir::attach_plugin_store(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
load_prefs(&mut app, saved);
|
||||
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
|
||||
@@ -873,6 +880,8 @@ fn main() -> std::process::ExitCode {
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
|
||||
app.session.style_libraries.set_user_dir(styles);
|
||||
// With the User Defined libraries listed, the persistent library panels reopen.
|
||||
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
|
||||
// So do the Libraries panel's libraries; runs without preferences (agents' test
|
||||
// runs) keep theirs for the session only, never touching the user's.
|
||||
if prefs_enabled() {
|
||||
@@ -1042,9 +1051,9 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finder offers the app for every file File › Open reads (#295, #354) but Photoshop documents
|
||||
/// (`UNASSOCIATED_EXTS`), takes over no other app's files, and hands them to the app rather than
|
||||
/// to AppKit's document machinery.
|
||||
/// Finder offers the app for every file File › Open reads (#295, #354) but those with an
|
||||
/// extension in `UNASSOCIATED_EXTS`, takes over no other app's files, and hands them to the app
|
||||
/// rather than to AppKit's document machinery.
|
||||
#[test]
|
||||
fn the_macos_bundle_opens_every_readable_format() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
|
||||
@@ -41,6 +41,14 @@ pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the plug-ins Install Plug-in kept in the `Plug-ins` folder (#2541), and keep the ones
|
||||
/// installed from now on there.
|
||||
pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("Plug-ins") {
|
||||
session.attach_plugin_store(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
|
||||
/// now on there. After the preferences were restored: the folder is the one place the presets live.
|
||||
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
|
||||
|
||||
@@ -43,11 +43,32 @@ mod system {
|
||||
let settings = craft_print::Job { printer: job.printer.map(str::to_string), title: job.title.into(), ..Default::default() };
|
||||
craft_print::submit(job.pdf, &settings).map(|note| if note.is_empty() { note } else { format!(" ({note})") }).map_err(|e| e.to_string())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn setup_command(printer: Option<&str>, default_printer: Option<&str>) -> Command {
|
||||
let target = printer.map(str::trim).filter(|p| !p.is_empty()).or_else(|| default_printer.map(str::trim).filter(|p| !p.is_empty()));
|
||||
if let Some(name) = target {
|
||||
let mut cmd = Command::new("rundll32.exe");
|
||||
cmd.args(["printui.dll,PrintUIEntry", "/e", "/n", name]);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = Command::new("explorer");
|
||||
cmd.arg("ms-settings:printers");
|
||||
cmd
|
||||
}
|
||||
}
|
||||
pub fn setup(printer: Option<&str>) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = printer;
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
let list = printers();
|
||||
let default = list.iter().find(|p| p.default).or_else(|| list.first()).map(|p| p.name.as_str());
|
||||
// Only an installed printer's own name reaches the command line: `printer` can come
|
||||
// from a command or MCP, and PrintUIEntry reads options from its arguments.
|
||||
let printer = printer.map(str::trim).filter(|p| list.iter().any(|q| q.name == *p));
|
||||
let mut cmd = setup_command(printer, default);
|
||||
match cmd.spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -68,3 +89,41 @@ mod system {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))]
|
||||
mod tests {
|
||||
use super::system::setup_command;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn setup_command_uses_printui_for_specified_printer() {
|
||||
let cmd = setup_command(Some("Office Laser"), None);
|
||||
assert_eq!(cmd.get_program(), "rundll32.exe");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Office Laser"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_command_falls_back_to_default_printer() {
|
||||
let cmd = setup_command(None, Some("Default Proofer"));
|
||||
assert_eq!(cmd.get_program(), "rundll32.exe");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_command_trims_whitespace_and_skips_empty() {
|
||||
let cmd = setup_command(Some(" "), Some("Default Proofer"));
|
||||
assert_eq!(cmd.get_program(), "rundll32.exe");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["printui.dll,PrintUIEntry", "/e", "/n", "Default Proofer"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_command_falls_back_to_settings_when_no_printer() {
|
||||
let cmd = setup_command(None, None);
|
||||
assert_eq!(cmd.get_program(), "explorer");
|
||||
let args: Vec<&OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args, ["ms-settings:printers"]);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
|
||||
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
|
||||
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
|
||||
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
|
||||
//! never keeps the app from starting.
|
||||
//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app
|
||||
//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11
|
||||
//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and
|
||||
//! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland,
|
||||
//! so a missing library never keeps the app from starting.
|
||||
//!
|
||||
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
|
||||
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
|
||||
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories. The pen
|
||||
//! check comes from PhotoCraft's `tablet.rs`: the input devices the kernel lists in
|
||||
//! `/proc/bus/input/devices`.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::ffi::OsString;
|
||||
@@ -16,12 +20,50 @@ use std::path::{Path, PathBuf};
|
||||
const X11_LIBS: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
|
||||
|
||||
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
|
||||
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
|
||||
/// and an X display to open.
|
||||
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
|
||||
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or
|
||||
/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever
|
||||
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for
|
||||
/// `x11WithPen`, after the session checks.
|
||||
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>, pen: impl FnOnce() -> bool) -> bool {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
|
||||
let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY");
|
||||
match pref {
|
||||
Some("x11") => session,
|
||||
Some("x11WithPen") => session && pen(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key that a pen tablet's stylus, a pen display's
|
||||
/// pen or a pen-enabled touchscreen's pen reports.
|
||||
const BTN_TOOL_PEN: u32 = 0x140;
|
||||
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
|
||||
const ABS_X: u32 = 0x00;
|
||||
const ABS_Y: u32 = 0x01;
|
||||
|
||||
/// Is a pen attached? Reads `/proc/bus/input/devices`; a file that can't be read has no pen.
|
||||
pub fn pen_attached() -> bool {
|
||||
std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|devices| has_pen(&devices))
|
||||
}
|
||||
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
|
||||
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` and whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`, as
|
||||
/// libinput asks of a tablet tool. A virtual keyboard made with uinput can claim every key code,
|
||||
/// `BTN_TOOL_PEN` included, but has no absolute axes.
|
||||
fn has_pen(devices: &str) -> bool {
|
||||
devices.split("\n\n").any(|device| {
|
||||
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
|
||||
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN))
|
||||
&& bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
|
||||
/// significant first. Short or malformed bitmaps have no bits set.
|
||||
fn bit_set(bitmap: &str, bit: u32) -> bool {
|
||||
let bits = usize::BITS;
|
||||
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
|
||||
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
|
||||
}
|
||||
|
||||
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
|
||||
@@ -98,18 +140,88 @@ mod tests {
|
||||
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
fn no_probe() -> bool {
|
||||
panic!("the pen check ran")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
|
||||
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
|
||||
assert!(wanted(Some("x11"), &wayland));
|
||||
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("automatic"), &wayland));
|
||||
assert!(!wanted(None, &wayland));
|
||||
assert!(!wanted(Some("wayland"), &wayland));
|
||||
assert!(wanted(Some("x11"), &wayland, no_probe));
|
||||
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe));
|
||||
assert!(!wanted(Some("automatic"), &wayland, no_probe));
|
||||
assert!(!wanted(None, &wayland, no_probe));
|
||||
assert!(!wanted(Some("wayland"), &wayland, no_probe));
|
||||
// An X11 session already opens on X11; without an X display there is no XWayland to use.
|
||||
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
|
||||
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
|
||||
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
|
||||
}
|
||||
|
||||
/// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X
|
||||
/// display when a pen is found, the session's own display server otherwise. The pen check
|
||||
/// runs only on such a session.
|
||||
#[test]
|
||||
fn the_pen_choice_opens_xwayland_only_with_a_pen() {
|
||||
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
|
||||
assert!(wanted(Some("x11WithPen"), &wayland, || true));
|
||||
assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true));
|
||||
assert!(!wanted(Some("x11WithPen"), &wayland, || false));
|
||||
assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe));
|
||||
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
|
||||
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
|
||||
}
|
||||
|
||||
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
|
||||
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, `BTN_TOOL_PEN`), from PhotoCraft's tests.
|
||||
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
|
||||
N: Name=\"AT Translated Set 2 keyboard\"\n\
|
||||
H: Handlers=sysrq kbd event3 leds\n\
|
||||
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
|
||||
\n\
|
||||
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
|
||||
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
|
||||
H: Handlers=mouse1 event7\n\
|
||||
B: KEY=e520 10000 0 0 0 0\n\
|
||||
B: ABS=2e0800000000003\n\
|
||||
\n\
|
||||
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
|
||||
N: Name=\"Wacom Intuos Pro M Pen\"\n\
|
||||
H: Handlers=mouse2 event10\n\
|
||||
B: KEY=1c03 0 0 0 0 0\n\
|
||||
B: ABS=1000f000003\n";
|
||||
|
||||
/// A uinput virtual keyboard that claims every key code, `BTN_TOOL_PEN` included, and has no
|
||||
/// absolute axes (`EV=20013`, no `B: ABS=` line), from PhotoCraft's tests.
|
||||
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
|
||||
N: Name=\"Virtual Keyboard\"\n\
|
||||
H: Handlers=sysrq rfkill kbd event256 leds \n\
|
||||
B: EV=20013\n\
|
||||
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n";
|
||||
|
||||
#[test]
|
||||
fn a_pen_is_a_device_with_btn_tool_pen_and_absolute_axes() {
|
||||
assert!(has_pen(WITH_PEN));
|
||||
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
|
||||
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
|
||||
// A pen display reports the pen with its own axes.
|
||||
assert!(has_pen("N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"));
|
||||
// Axes without ABS_X or ABS_Y, or no axes at all, are no pen.
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
|
||||
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
|
||||
assert!(!has_pen(&pen_without_axes));
|
||||
// The virtual keyboard claims the key but is no pen, and a real pen beside it is still found.
|
||||
assert!(VIRTUAL_KEYBOARD.lines().find_map(|l| l.strip_prefix("B: KEY=")).is_some_and(|k| bit_set(k, BTN_TOOL_PEN)));
|
||||
assert!(!has_pen(VIRTUAL_KEYBOARD));
|
||||
assert!(has_pen(&format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}")));
|
||||
// Short or malformed bitmaps are no pen, never a panic.
|
||||
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
|
||||
assert!(!has_pen(&format!("{keys}\nB: ABS=3")), "{keys:?}");
|
||||
}
|
||||
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
|
||||
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -123,3 +123,9 @@ sync_github_name = false
|
||||
|
||||
[people.theromis]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dennismutuku2005]
|
||||
sync_github_name = true
|
||||
|
||||
[people.makesomethingshit]
|
||||
sync_github_name = true
|
||||
@@ -387,7 +387,9 @@ impl Paint {
|
||||
}
|
||||
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
|
||||
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
|
||||
/// left without a link gets tint 1. Returns whether anything changed.
|
||||
/// left without a link gets tint 1. A freeform gradient's points, which it draws from, go
|
||||
/// through `f` too, as unlinked colours (a link `f` gives them is dropped). Returns whether
|
||||
/// anything changed.
|
||||
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
|
||||
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
|
||||
let changed = f(c, link, tint);
|
||||
@@ -398,7 +400,12 @@ impl Paint {
|
||||
};
|
||||
match self {
|
||||
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch),
|
||||
Paint::Gradient(g) => {
|
||||
let stops = g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch);
|
||||
let points =
|
||||
g.freeform.iter_mut().flat_map(|ff| ff.points.iter_mut()).fold(false, |ch, p| one(&mut p.color, &mut None, &mut 1.0) | ch);
|
||||
stops | points
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ vectorcraft-color = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
tiff = { workspace = true }
|
||||
# 1-bit TIFFs compressed with CCITT Group 3 (Compression 2 and 3).
|
||||
hayro-ccitt = { workspace = true }
|
||||
zune-jpeg = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -201,6 +201,53 @@ impl ImageBlob {
|
||||
let bytes = self.bytes.as_slice();
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }
|
||||
}
|
||||
|
||||
/// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image
|
||||
/// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered
|
||||
/// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images
|
||||
/// [`Self::cmyk`] doesn't read. The inks of the last CMYK image are kept for the next sample
|
||||
/// (the Eyedropper samples one image click after click), as [`Self::sample_object`] keeps its
|
||||
/// pixels.
|
||||
pub fn sample_cmyk_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> {
|
||||
thread_local! {
|
||||
static LAST: std::cell::RefCell<Option<(String, Arc<Inks>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
if !self.is_cmyk() {
|
||||
return None;
|
||||
}
|
||||
let key = self.content_key();
|
||||
let inks = LAST.with_borrow_mut(|last| match last {
|
||||
Some((k, inks)) if *k == key => Some(inks.clone()),
|
||||
_ => {
|
||||
let inks = Arc::new(self.cmyk()?);
|
||||
*last = Some((key, inks.clone()));
|
||||
Some(inks)
|
||||
}
|
||||
})?;
|
||||
let k = |n: u32, of: u32| n as f64 / of.max(1) as f64;
|
||||
sample_inks(&inks, x * k(inks.width, object.0), y * k(inks.height, object.1), size)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImageBlob::sample_cmyk_object`] of `inks` at (`x`, `y`) in their own pixel space.
|
||||
fn sample_inks(inks: &Inks, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
|
||||
let (w, h) = (i64::from(inks.width), i64::from(inks.height));
|
||||
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
|
||||
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
|
||||
return None;
|
||||
}
|
||||
let r = i64::from(size.max(1)) / 2;
|
||||
let (mut sum, mut n) = ([0u64; 4], 0u64);
|
||||
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
|
||||
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
|
||||
let i = usize::try_from((py * w + px) * 4).ok()?;
|
||||
let p = inks.data.get(i..i + 4)?;
|
||||
sum.iter_mut().zip(p).for_each(|(s, v)| *s += u64::from(*v));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let c = |v: u64| ((v + n / 2) / n) as u8;
|
||||
Some(sum.map(c))
|
||||
}
|
||||
|
||||
/// The ink amounts of a four-component JPEG (its samples inverted back; YCCK converted to CMYK
|
||||
@@ -302,6 +349,162 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
|
||||
image::RgbaImage::from_raw(w, h, out)
|
||||
}
|
||||
|
||||
/// Why a TIFF compressed with CCITT Group 3 wasn't read ([`group3_tiff_rgba`]).
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
pub enum Group3Error {
|
||||
/// Its pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA.
|
||||
#[error("an image of {w} × {h} pixels is too large to draw")]
|
||||
TooLarge { w: u32, h: u32 },
|
||||
/// A strip or tile ends before its last row or can't be decoded (it holds a code the decoder
|
||||
/// doesn't read, such as T.4's uncompressed mode, or a row of the wrong width), or a tile is
|
||||
/// larger than [`crate::MAX_RASTER_ALLOC`] allows as RGBA.
|
||||
#[error("could not decode image: {0}")]
|
||||
Damaged(hayro_ccitt::DecodeError),
|
||||
}
|
||||
|
||||
/// The pixels of a 1-bit TIFF compressed with CCITT Group 3, which the image decoder doesn't read,
|
||||
/// as RGBA. Compression 2 is modified Huffman coding with each row starting at a byte and no EOL
|
||||
/// codes. Compression 3 is ITU-T T.4 coding with an EOL code before each row: one-dimensional rows,
|
||||
/// or with T4Options bit 0, rows that a tag bit marks as one-dimensional or as coded against the
|
||||
/// row above. Fill bits before an EOL are skipped. Rows in T.4's uncompressed mode (T4Options
|
||||
/// bit 1) can't be decoded. Each strip or tile is coded on its own. White runs are 0 bits and black
|
||||
/// runs are 1 bits, as libtiff writes them, and PhotometricInterpretation 0 shows 0 bits as white
|
||||
/// and 1 shows them as black. FillOrder 2 (each byte's lowest bit first) is read too. Together, the
|
||||
/// strips and tiles are read from at most as many bytes as the file has. `None` for other images;
|
||||
/// an error when the pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA, a tile is
|
||||
/// larger than that allowance, or a strip or tile can't be decoded or ends before its last row.
|
||||
pub fn group3_tiff_rgba(bytes: &[u8]) -> Option<Result<image::RgbaImage, Group3Error>> {
|
||||
use hayro_ccitt::{DecodeError, DecodeSettings, DecoderContext, EncodingMode};
|
||||
use tiff::tags::Tag;
|
||||
let (kind, mut d) = tiff(bytes)?;
|
||||
let compression = d.find_tag_unsigned::<u16>(Tag::Compression).ok().flatten()?;
|
||||
let (w, h) = d.dimensions().ok()?;
|
||||
if kind != tiff::ColorType::Gray(1) || !matches!(compression, 2 | 3) || w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// The level of a 0 bit (a white run): white when white is zero (0), black when black is zero (1).
|
||||
let zero = match d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() {
|
||||
Some(0) => u8::MAX,
|
||||
Some(1) => 0,
|
||||
_ => return None,
|
||||
};
|
||||
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
|
||||
return Some(Err(Group3Error::TooLarge { w, h }));
|
||||
}
|
||||
let t4_options = d.find_tag_unsigned::<u32>(Tag::Unknown(292)).ok().flatten().unwrap_or(0);
|
||||
let lsb_first = d.find_tag_unsigned::<u16>(Tag::FillOrder).ok().flatten() == Some(2);
|
||||
let encoding = if compression == 3 && t4_options & 1 != 0 {
|
||||
// Each row's tag bit says how it is coded.
|
||||
EncodingMode::Group3_2D { k: 2 }
|
||||
} else {
|
||||
EncodingMode::Group3_1D
|
||||
};
|
||||
// The width and height of each strip or tile, and the tags of their offsets and byte counts.
|
||||
let tiled = d.get_chunk_type() == tiff::decoder::ChunkType::Tile;
|
||||
let (cw, ch, offsets, counts) = if tiled {
|
||||
let tw = d.find_tag_unsigned::<u32>(Tag::TileWidth).ok().flatten().filter(|&v| v > 0)?;
|
||||
let th = d.find_tag_unsigned::<u32>(Tag::TileLength).ok().flatten().filter(|&v| v > 0)?;
|
||||
(tw, th, Tag::TileOffsets, Tag::TileByteCounts)
|
||||
} else {
|
||||
let rows = d.find_tag_unsigned::<u32>(Tag::RowsPerStrip).ok().flatten().filter(|&v| v > 0).map_or(h, |r| r.min(h));
|
||||
(w, rows, Tag::StripOffsets, Tag::StripByteCounts)
|
||||
};
|
||||
let damaged = |e: DecodeError| Some(Err(Group3Error::Damaged(e)));
|
||||
// A tile is held to the allowance the image is held to; a strip is no larger than the image.
|
||||
if u64::from(cw) * u64::from(ch) > crate::MAX_RASTER_ALLOC / 4 {
|
||||
return damaged(DecodeError::Overflow);
|
||||
}
|
||||
let offsets = d.find_tag_unsigned_vec::<u64>(offsets).ok().flatten().unwrap_or_default();
|
||||
let counts = d.find_tag_unsigned_vec::<u64>(counts).ok().flatten().unwrap_or_default();
|
||||
let across = w.div_ceil(cw);
|
||||
let chunks = u64::from(across) * u64::from(h.div_ceil(ch));
|
||||
if chunks > offsets.len().min(counts.len()) as u64 {
|
||||
return damaged(DecodeError::UnexpectedEof);
|
||||
}
|
||||
let mut gray = vec![zero; usize::try_from(u64::from(w) * u64::from(h)).ok()?];
|
||||
let mut budget = bytes.len();
|
||||
for (i, (&offset, &count)) in (0..chunks).zip(offsets.iter().zip(&counts)) {
|
||||
let (x0, y0) = (u32::try_from(i % u64::from(across)).ok()? * cw, u32::try_from(i / u64::from(across)).ok()? * ch);
|
||||
let Some(start) = usize::try_from(offset).ok().filter(|&s| s <= bytes.len()) else {
|
||||
return damaged(DecodeError::UnexpectedEof);
|
||||
};
|
||||
let len = usize::try_from(count).unwrap_or(usize::MAX).min(bytes.len() - start).min(budget);
|
||||
budget -= len;
|
||||
let data = bytes.get(start..start + len)?;
|
||||
let data: std::borrow::Cow<[u8]> = if lsb_first { data.iter().map(|b| b.reverse_bits()).collect() } else { data.into() };
|
||||
let settings = DecodeSettings {
|
||||
columns: cw,
|
||||
// A tile is coded whole; the last strip only has the image's remaining rows.
|
||||
rows: if tiled { ch } else { ch.min(h - y0) },
|
||||
end_of_block: compression == 3,
|
||||
end_of_line: compression == 3,
|
||||
rows_are_byte_aligned: compression == 2,
|
||||
encoding,
|
||||
invert_black: false,
|
||||
};
|
||||
let mut rows = Rows { out: &mut gray, width: w, height: h, x0, y0, x: 0, y: 0, zero };
|
||||
if let Err(e) = hayro_ccitt::decode(&data, &mut rows, &mut DecoderContext::new(settings)) {
|
||||
return damaged(e);
|
||||
}
|
||||
// The data ended, or six EOL codes came, before the strip's or tile's last row.
|
||||
if rows.y < settings.rows {
|
||||
return damaged(DecodeError::UnexpectedEof);
|
||||
}
|
||||
}
|
||||
let gray = image::GrayImage::from_raw(w, h, gray)?;
|
||||
Some(Ok(image::DynamicImage::ImageLuma8(gray).to_rgba8()))
|
||||
}
|
||||
|
||||
/// Where a strip or tile of a Group 3 TIFF ([`group3_tiff_rgba`]) puts its decoded pixels: `out`
|
||||
/// holds the image's levels row by row, `width` × `height`, and the strip or tile starts at (`x0`,
|
||||
/// `y0`). Pixels past the image's right or bottom edge (a tile's padding) are dropped.
|
||||
struct Rows<'a> {
|
||||
out: &'a mut [u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
x0: u32,
|
||||
y0: u32,
|
||||
/// The next pixel in the strip or tile.
|
||||
x: u64,
|
||||
y: u32,
|
||||
/// The level of a white run.
|
||||
zero: u8,
|
||||
}
|
||||
|
||||
impl Rows<'_> {
|
||||
/// The next `n` pixels of the row, a white run's or a black run's.
|
||||
fn run(&mut self, white: bool, n: u64) {
|
||||
let level = if white { self.zero } else { u8::MAX - self.zero };
|
||||
let row = u64::from(self.y0) + u64::from(self.y);
|
||||
let left = u64::from(self.x0) + self.x;
|
||||
let right = left.saturating_add(n).min(u64::from(self.width));
|
||||
if row < u64::from(self.height) && left < right {
|
||||
let at = row * u64::from(self.width);
|
||||
if let (Ok(a), Ok(b)) = (usize::try_from(at + left), usize::try_from(at + right))
|
||||
&& let Some(px) = self.out.get_mut(a..b)
|
||||
{
|
||||
px.fill(level);
|
||||
}
|
||||
}
|
||||
self.x = self.x.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
impl hayro_ccitt::Decoder for Rows<'_> {
|
||||
fn push_pixel(&mut self, white: bool) {
|
||||
self.run(white, 1);
|
||||
}
|
||||
|
||||
fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) {
|
||||
self.run(white, u64::from(chunk_count) * 8);
|
||||
}
|
||||
|
||||
fn next_line(&mut self) {
|
||||
self.y = self.y.saturating_add(1);
|
||||
self.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -430,6 +633,20 @@ mod tests {
|
||||
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_image_samples_its_ink_amounts() {
|
||||
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
|
||||
assert_eq!(b.sample_cmyk_object(0.5, 0.5, (3, 2), 1), Some([0, 40, 100, 0]));
|
||||
// An object of 6 × 4 pixels showing these 3 × 2: (5, 3) is the last pixel.
|
||||
assert_eq!(b.sample_cmyk_object(5.0, 3.0, (6, 4), 1), Some([12, 34, 56, 78]));
|
||||
// 3 × 3 around (1, 0), clipped to the image: all six pixels averaged.
|
||||
assert_eq!(b.sample_cmyk_object(1.5, 0.5, (3, 2), 3), Some([45, 55, 69, 56]));
|
||||
assert_eq!(b.sample_cmyk_object(3.5, 0.5, (3, 2), 1), None, "outside");
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(ImageBlob::png(png).sample_cmyk_object(0.5, 0.5, (2, 2), 1), None, "an RGB image has no inks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_jpeg_reads_back_as_ink_amounts() {
|
||||
// A flat CMYK JPEG: Adobe's inverted samples with its marker.
|
||||
|
||||
@@ -196,6 +196,57 @@ macro_rules! ids {
|
||||
ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" });
|
||||
ids!(PieSort { None => "none", All => "all", First => "first" });
|
||||
|
||||
/// Object › Graph › Column › Column Type: how a column design fills a column or bar. Vertically Scaled stretches it
|
||||
/// along the value axis to the column's length at the column's width; Uniformly Scaled scales it both ways to that
|
||||
/// length, centered on the column; Repeating stacks copies that each stand for [`ColumnDesign::each`]; Sliding scales the
|
||||
/// part above its guide line (in a bar, right of it) in proportion to the column's width and stretches the rest.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColumnType {
|
||||
#[default]
|
||||
Vertical,
|
||||
Uniform,
|
||||
Repeating,
|
||||
Sliding,
|
||||
}
|
||||
|
||||
/// Object › Graph › Column › For Fractions: a repeating design's last copy, for the part of a value less than one
|
||||
/// design, is cut off (Chop) or scaled down along the value axis (Scale).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Fractions {
|
||||
#[default]
|
||||
Chop,
|
||||
Scale,
|
||||
}
|
||||
|
||||
ids!(ColumnType { Vertical => "vertical", Uniform => "uniform", Repeating => "repeating", Sliding => "sliding" });
|
||||
ids!(Fractions { Chop => "chop", Scale => "scale" });
|
||||
|
||||
/// Object › Graph › Column: the graph design a series' columns or bars are drawn with, and how it fills them.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ColumnDesign {
|
||||
pub design: String,
|
||||
#[serde(skip_serializing_if = "ColumnType::is_default")]
|
||||
pub column_type: ColumnType,
|
||||
/// Repeating: the value one copy of the design stands for (Each Design Represents).
|
||||
pub each: f64,
|
||||
#[serde(skip_serializing_if = "Fractions::is_default")]
|
||||
pub fractions: Fractions,
|
||||
}
|
||||
|
||||
impl ColumnDesign {
|
||||
/// Each Design Represents for a new column design.
|
||||
pub const EACH: f64 = 10.0;
|
||||
}
|
||||
|
||||
impl Default for ColumnDesign {
|
||||
fn default() -> Self {
|
||||
Self { design: String::new(), column_type: ColumnType::Vertical, each: Self::EACH, fractions: Fractions::Chop }
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -345,6 +396,11 @@ pub struct GraphSpec {
|
||||
/// series index, for line, scatter and radar series; `None` and missing entries draw the default square.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_markers: Vec<Option<String>>,
|
||||
/// Object › Graph › Column: the graph design each series' columns or bars (and legend swatch) are drawn with, by
|
||||
/// series index, for column, stacked column, bar and stacked bar series; `None` and missing entries draw plain
|
||||
/// columns.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_columns: Vec<Option<ColumnDesign>>,
|
||||
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
|
||||
/// length, and tick marks per division (0 and 1 = one, at the labels).
|
||||
#[serde(skip_serializing_if = "TickLength::is_short")]
|
||||
@@ -479,6 +535,7 @@ impl Default for GraphSpec {
|
||||
right_axis_min: None,
|
||||
right_axis_max: None,
|
||||
series_markers: vec![],
|
||||
series_columns: vec![],
|
||||
label_styles: LabelStyles::default(),
|
||||
first_row_in_front: false,
|
||||
first_column_in_front: false,
|
||||
|
||||
+234
-25
@@ -294,19 +294,24 @@ fn edits_contents(n: &Node) -> bool {
|
||||
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
|
||||
/// glyph outlines need the font engine, above this crate.
|
||||
fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
clip_regions(clip).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
}
|
||||
|
||||
/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in
|
||||
/// [`clip_contains`].
|
||||
fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> {
|
||||
let frame = |n: &Node| {
|
||||
let b = n.geometric_bounds()?;
|
||||
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
|
||||
};
|
||||
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
|
||||
clip.clip_shapes(Some(&frame))
|
||||
}
|
||||
|
||||
/// 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, symbols: &SymbolBoxes, 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) {
|
||||
if shaper_hides(parent, i, opt.scope) {
|
||||
return false;
|
||||
}
|
||||
// Read-only samples can read locked/template art; hidden art never participates.
|
||||
@@ -315,10 +320,22 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
|
||||
}
|
||||
// (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.extras().shaper.is_some() && source_of(c, opt.scope)
|
||||
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
}
|
||||
|
||||
/// Is the first child of `n` the isolated container `scope`?
|
||||
fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
|
||||
n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == scope)
|
||||
}
|
||||
|
||||
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
|
||||
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
|
||||
/// isolated, the art the group shows.
|
||||
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
|
||||
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
|
||||
}
|
||||
|
||||
fn hit_children(
|
||||
parent: &Node,
|
||||
p: Point,
|
||||
@@ -464,15 +481,39 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
|
||||
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
|
||||
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
|
||||
pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
|
||||
let NodeKind::Compound { children, rule } = &n.kind else { return None };
|
||||
let parts: Vec<_> = children.iter().rev().filter_map(|c| c.path_data().map(|pd| (c.id, pd.to_bezpath(), pd.bounds()))).collect();
|
||||
if let Some((id, ..)) = parts.iter().find(|(_, bp, _)| stroke_contains(bp, 0.0, tol, p)) {
|
||||
return Some(*id);
|
||||
}
|
||||
// The innermost by the size of its box.
|
||||
let size = |b: &Option<Rect>| b.map_or(f64::INFINITY, |b| b.area());
|
||||
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
|
||||
}
|
||||
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
|
||||
/// instances are measured by their art ([`Node::geometric_bounds_with`]).
|
||||
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
|
||||
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
|
||||
/// path clips to, and then only within that region's bounds.
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => match s.children() {
|
||||
Some(c) => c.iter().collect(),
|
||||
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
|
||||
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => match (s.children(), marquee_in(s, r)) {
|
||||
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
|
||||
(Some(_), None) => vec![],
|
||||
// A single object isolated: only it.
|
||||
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
|
||||
(None, _) => doc
|
||||
.parent_of(s.id)
|
||||
.and_then(|p| doc.node(p))
|
||||
.and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
|
||||
.into_iter()
|
||||
.flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
|
||||
.collect(),
|
||||
},
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
// are left out).
|
||||
@@ -480,28 +521,33 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| layer_objects(l))
|
||||
.flat_map(|l| layer_objects(l, r))
|
||||
.collect(),
|
||||
};
|
||||
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
|
||||
/// The objects of layer `l`, each with the part of `r` that the clipping masks of `l` and of
|
||||
/// the sublayers it is in leave.
|
||||
fn layer_objects(l: &Node, r: Rect) -> Vec<(&std::sync::Arc<Node>, Rect)> {
|
||||
let mut out = vec![];
|
||||
let Some(r) = marquee_in(l, r) else { return out };
|
||||
for c in l.children().into_iter().flatten() {
|
||||
if c.is_layer() {
|
||||
if c.visible && !c.locked && !c.is_template() {
|
||||
out.extend(layer_objects(c));
|
||||
out.extend(layer_objects(c, r));
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
out.push((c, r));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
|
||||
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
|
||||
/// ([`shaper_hides`]).
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
children.iter().any(|c| c.visible && touches(c, s, r))
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => marquee_in(n, r)
|
||||
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
|
||||
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
|
||||
// A box with no width or no height (an instance of a symbol that draws a straight
|
||||
// line) is touched where the marquee crosses it.
|
||||
_ => n.geometric_bounds_with(s).is_some_and(|b| {
|
||||
@@ -510,36 +556,67 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
for c in children {
|
||||
if c.visible && !c.locked {
|
||||
collect_leaves(c, s, r, out);
|
||||
let Some(r) = marquee_in(n, r) else { return };
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
|
||||
collect_leaves(c, s, r, scope, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if touches(n, s, r) {
|
||||
if touches(n, s, r, scope) {
|
||||
out.push(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
for t in tops {
|
||||
for (t, r) in tops {
|
||||
if !t.visible || t.locked {
|
||||
continue;
|
||||
}
|
||||
if leaves {
|
||||
collect_leaves(t, &symbols, r, &mut out);
|
||||
} else if touches(t, &symbols, r) {
|
||||
collect_leaves(t, &symbols, r, scope, &mut out);
|
||||
} else if touches(t, &symbols, r, scope) {
|
||||
out.push(t.id);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a
|
||||
/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None
|
||||
/// when the marquee misses it); otherwise it is all of `r`.
|
||||
fn marquee_in(parent: &Node, r: Rect) -> Option<Rect> {
|
||||
match parent.children().and_then(|c| c.first()) {
|
||||
Some(clip) if parent.clips() => marquee_in_clip(clip, r),
|
||||
_ => Some(r),
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a marquee `r` in which it can touch the art that `clip` clips (the clipping path of
|
||||
/// a clip group or of a layer with a clipping mask): `r` cut to the bounds of the regions `clip`
|
||||
/// clips to ([`clip_regions`]), or `None` when `r` meets none of those regions. Within those
|
||||
/// bounds, art outside a region that is not a rectangle still counts.
|
||||
fn marquee_in_clip(clip: &Node, r: Rect) -> Option<Rect> {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let regions = clip_regions(clip);
|
||||
// The edge of a region is its outline with each open subpath closed, as `fill_contains` fills it.
|
||||
let meets = |(bp, rule): &(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)| {
|
||||
let mut edge = vectorcraft_geom::PathData::from_bezpath(bp);
|
||||
edge.subpaths.iter_mut().for_each(|sp| sp.closed = true);
|
||||
fill_contains(bp, *rule, r.center()) || vectorcraft_geom::hit::intersects_rect(&edge, r)
|
||||
};
|
||||
if !regions.iter().any(meets) {
|
||||
return None;
|
||||
}
|
||||
let bounds = regions.iter().map(|(bp, _)| bp.bounding_box()).reduce(|a, b| a.union(b))?;
|
||||
Some(r.intersect(bounds))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -613,6 +690,138 @@ mod tests {
|
||||
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
|
||||
}
|
||||
|
||||
/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and
|
||||
/// the art, in that order.
|
||||
fn half_clipped(d: &mut Document) -> (Node, Node) {
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||||
(mask, art)
|
||||
}
|
||||
|
||||
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
|
||||
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
|
||||
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
|
||||
/// Group Selection's leaves and in isolation mode.
|
||||
#[test]
|
||||
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id());
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
|
||||
// This marquee crosses only the hidden art's top and right edges.
|
||||
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
|
||||
assert!(marquee(&d, hidden, None, false).is_empty());
|
||||
assert!(marquee(&d, hidden, None, true).is_empty());
|
||||
assert!(marquee(&d, hidden, Some(g), false).is_empty());
|
||||
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
|
||||
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
|
||||
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
|
||||
// This marquee crosses the shown half's top edge and goes on into the hidden half.
|
||||
let shown = Rect::new(50.0, -10.0, 160.0, 10.0);
|
||||
assert_eq!(marquee(&d, shown, None, false), vec![g]);
|
||||
assert!(marquee(&d, shown, None, true).contains(&art_id));
|
||||
assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art");
|
||||
}
|
||||
|
||||
/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the
|
||||
/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a
|
||||
/// straight line, and a marquee across that line takes the art it reaches inside.
|
||||
#[test]
|
||||
fn a_marquee_must_meet_the_clipping_path_itself() {
|
||||
// A clip group of a mask through `pts` (closed or open) over the art `art`.
|
||||
let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut mask = vectorcraft_geom::BezPath::new();
|
||||
mask.move_to(pts[0]);
|
||||
for &p in &pts[1..] {
|
||||
mask.line_to(p);
|
||||
}
|
||||
if closed {
|
||||
mask.close_path();
|
||||
}
|
||||
let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default());
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art());
|
||||
let g = d.alloc_id();
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
(d, g)
|
||||
};
|
||||
let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty());
|
||||
assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point");
|
||||
// The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it.
|
||||
let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0));
|
||||
assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]);
|
||||
}
|
||||
|
||||
/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the
|
||||
/// mask shows them, as in a clip group.
|
||||
#[test]
|
||||
fn a_marquee_leaves_art_a_layer_clips_away() {
|
||||
for sublayer in [false, true] {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let beside = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
|
||||
let masked = d.alloc_id();
|
||||
let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2));
|
||||
layer.set_clips(true);
|
||||
d.insert(sublayer.then_some(l), 9, layer).unwrap();
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (mask_id, art_id) = (mask.id, art.id);
|
||||
d.insert(Some(masked), 0, mask).unwrap();
|
||||
d.insert(Some(masked), 1, art).unwrap();
|
||||
assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}");
|
||||
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}");
|
||||
assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
|
||||
/// the original shape of a face the group erased it selects nothing, with Group Selection's
|
||||
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
|
||||
/// (Construction mode) it takes the original shape it reaches.
|
||||
#[test]
|
||||
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||||
let b_id = b.id;
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
sources.name = Some(crate::shaper::SOURCES.into());
|
||||
sources.opacity = 0.0;
|
||||
let sources_id = sources.id;
|
||||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
|
||||
let face_id = face.id;
|
||||
let g = d.alloc_id();
|
||||
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
|
||||
group.extras_mut().shaper = Some(Box::default());
|
||||
d.insert(Some(l), 0, group).unwrap();
|
||||
// This marquee crosses only the right edge of the second square, whose face is erased.
|
||||
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
|
||||
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
|
||||
assert!(marquee(&d, erased, None, false).is_empty());
|
||||
assert!(marquee(&d, erased, None, true).is_empty());
|
||||
assert!(marquee(&d, erased, Some(g), false).is_empty());
|
||||
// This marquee crosses the shown face's left edge.
|
||||
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||||
assert_eq!(marquee(&d, shown, None, false), vec![g]);
|
||||
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
|
||||
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
|
||||
// Construction mode: the original shapes are the objects.
|
||||
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
|
||||
}
|
||||
|
||||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||||
/// sublayer; hidden, locked and template sublayers block both.
|
||||
#[test]
|
||||
|
||||
+160
-5
@@ -22,13 +22,16 @@ mod inline;
|
||||
pub mod intertwine;
|
||||
pub mod shared;
|
||||
mod symbol_export_type;
|
||||
mod symbol_nine_slice;
|
||||
mod symbol_registration;
|
||||
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
|
||||
pub use shared::Shared;
|
||||
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
|
||||
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod live_paint;
|
||||
pub mod marks;
|
||||
mod mem;
|
||||
pub mod metadata;
|
||||
@@ -90,17 +93,18 @@ pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::MAX_DEPTH;
|
||||
pub use graph::{
|
||||
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
|
||||
ValueAxisSide,
|
||||
ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition,
|
||||
PieSort, SeriesPaint, TickLength, ValueAxisSide,
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use live_paint::{GapOptions, GapSize};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask};
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
@@ -592,6 +596,23 @@ pub struct ImageBlob {
|
||||
pub proxy: Option<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a
|
||||
/// new document, or one origin for the whole document.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RulerMode {
|
||||
#[default]
|
||||
Artboard,
|
||||
Global,
|
||||
}
|
||||
|
||||
impl RulerMode {
|
||||
/// The mode of a document saved before the choice existed: global, as its rulers measured.
|
||||
pub fn legacy() -> Self {
|
||||
Self::Global
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
pub version: u32,
|
||||
@@ -605,6 +626,15 @@ pub struct Document {
|
||||
/// The rulers' zero point in document coordinates. Moving it changes measurements, never art.
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub ruler_origin: Point,
|
||||
/// View › Rulers › Change to Global / Artboard Rulers: whether the rulers and position fields
|
||||
/// measure from [`Self::ruler_origin`] (global) or from the active artboard's own origin.
|
||||
/// Documents saved before the choice existed measured globally and open that way.
|
||||
#[serde(default = "RulerMode::legacy")]
|
||||
pub rulers: RulerMode,
|
||||
/// Artboard rulers' zero point of each artboard (by [`Artboard::id`]), as an offset from its
|
||||
/// top-left corner; an artboard not listed measures from that corner.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub artboard_ruler_origins: BTreeMap<u32, Vec2>,
|
||||
#[serde(default)]
|
||||
pub color_mode: ColorMode,
|
||||
pub artboards: Vec<Artboard>,
|
||||
@@ -695,6 +725,10 @@ pub struct Document {
|
||||
/// save time only, so changing the view never marks the document modified.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_view: Option<SavedView>,
|
||||
/// View → Show Transparency Grid when the document was saved. Written at save time only,
|
||||
/// like [`Document::last_view`]; older files open with the grid hidden.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub transparency_grid: bool,
|
||||
/// The layers, sublayers and groups open in the Layers panel when the document was saved; they
|
||||
/// reopen that way. `None`: the default, only the top-level layers open. Written at save time
|
||||
/// only, like [`Document::last_view`].
|
||||
@@ -727,6 +761,10 @@ pub struct Document {
|
||||
/// export settings are Export for Screens' ([`Document::export_settings`]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<ExportAsset>,
|
||||
/// Window › SVG Interactivity › Link JavaScript Files: the JavaScript files (paths or URLs, as
|
||||
/// given) SVG export links with `<script>` elements, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_scripts: Vec<String>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
@@ -748,6 +786,14 @@ fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Is point `p` on the artboard at `rect`, its edges included, within a millionth of a point? This
|
||||
/// is the rule [`Document::art_on_artboard`] applies to art flush with an edge (#890), where
|
||||
/// `Rect::contains` leaves out the right and bottom edges.
|
||||
pub fn artboard_holds(rect: Rect, p: Point) -> bool {
|
||||
let r = rect.inflate(1e-6, 1e-6);
|
||||
r.x0 <= p.x && p.x <= r.x1 && r.y0 <= p.y && p.y <= r.y1
|
||||
}
|
||||
|
||||
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
|
||||
/// of each listed child in it, in order, with the child's place in the list.
|
||||
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
@@ -762,6 +808,27 @@ impl Document {
|
||||
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
|
||||
}
|
||||
|
||||
/// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`]
|
||||
/// with global rulers; with artboard rulers that artboard's top-left corner moved by its own
|
||||
/// offset (the first artboard's when `index` is out of range, the global zero point when the
|
||||
/// document has no artboard or the result is out of the supported range).
|
||||
pub fn ruler_origin_for(&self, index: usize) -> Point {
|
||||
if self.rulers == RulerMode::Global {
|
||||
return self.ruler_origin_or_zero();
|
||||
}
|
||||
let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() };
|
||||
let corner = Point::new(ab.rect.x0, ab.rect.y0);
|
||||
let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO);
|
||||
let origin = corner + offset;
|
||||
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD {
|
||||
origin
|
||||
} else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD {
|
||||
corner
|
||||
} else {
|
||||
Point::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
|
||||
pub fn new(width: f64, height: f64) -> Self {
|
||||
Self::new_with_mode(width, height, ColorMode::Rgb)
|
||||
@@ -776,6 +843,8 @@ impl Document {
|
||||
template: false,
|
||||
units: Unit::Points,
|
||||
ruler_origin: Point::ZERO,
|
||||
rulers: RulerMode::Artboard,
|
||||
artboard_ruler_origins: BTreeMap::new(),
|
||||
color_mode: mode,
|
||||
artboards: vec![Artboard {
|
||||
id: 1,
|
||||
@@ -813,6 +882,7 @@ impl Document {
|
||||
metadata: DocMetadata::default(),
|
||||
raster_effects: RasterEffectsSettings::default(),
|
||||
last_view: None,
|
||||
transparency_grid: false,
|
||||
layers_open: None,
|
||||
color_profiles: ColorProfiles::default(),
|
||||
export_settings: Default::default(),
|
||||
@@ -821,6 +891,7 @@ impl Document {
|
||||
slices_clip_to_artboard: true,
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
puppet: None,
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
@@ -1293,9 +1364,24 @@ impl Document {
|
||||
let symbols = self.symbol_boxes();
|
||||
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
|
||||
}
|
||||
/// Artboard index containing point `p` (topmost = last).
|
||||
/// Artboard index containing point `p` (topmost = last), its edges included
|
||||
/// ([`Document::artboard_holding`]).
|
||||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||||
self.artboards.iter().rposition(|a| a.rect.contains(p))
|
||||
self.artboard_holding(p, true)
|
||||
}
|
||||
/// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it
|
||||
/// inside or on its left or top edge, else the first (the last) that holds it by
|
||||
/// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an
|
||||
/// edge. On an edge two artboards share, the artboard to the right of it or below it holds the
|
||||
/// point, except at the end of the edge that lies on that artboard's bottom or right edge, such
|
||||
/// as the point where both artboards' bottom edges meet; there the first (the last) holds it.
|
||||
pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option<usize> {
|
||||
let pick = |on: &dyn Fn(Rect) -> bool| {
|
||||
let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect));
|
||||
let mut all = 0..self.artboards.len();
|
||||
if topmost { all.rfind(on) } else { all.find(on) }
|
||||
};
|
||||
pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p)))
|
||||
}
|
||||
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
|
||||
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
|
||||
@@ -1627,6 +1713,37 @@ impl Document {
|
||||
self.clear_symbol_export_type(from);
|
||||
self.set_symbol_export_type(to, kind);
|
||||
}
|
||||
|
||||
/// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default).
|
||||
pub fn symbol_nine_slice(&self, name: &str) -> Option<SymbolNineSlice> {
|
||||
self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value)
|
||||
}
|
||||
|
||||
/// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`.
|
||||
pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option<SymbolNineSlice>) {
|
||||
let Some(g) = guides else {
|
||||
let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return };
|
||||
o.remove(name);
|
||||
if o.is_empty() {
|
||||
self.unknown.remove(SYMBOL_NINE_SLICE);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({}));
|
||||
if let Some(o) = m.as_object_mut() {
|
||||
o.insert(name.into(), g.to_value());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the stored 9-slice guides when a symbol is renamed.
|
||||
pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
}
|
||||
let g = self.symbol_nine_slice(from);
|
||||
self.set_symbol_nine_slice(from, None);
|
||||
self.set_symbol_nine_slice(to, g);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
@@ -1745,6 +1862,44 @@ mod tests {
|
||||
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_at_counts_the_right_and_bottom_edges() {
|
||||
// Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it.
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] {
|
||||
let mut a = d.artboards[0].clone();
|
||||
a.rect = rect;
|
||||
d.artboards.push(a);
|
||||
}
|
||||
let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false));
|
||||
// Each artboard's own right and bottom edges, edges with roundoff, and points beyond them.
|
||||
assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge");
|
||||
assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge");
|
||||
assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner");
|
||||
assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge");
|
||||
assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge");
|
||||
assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge");
|
||||
assert_eq!(held(&d, 200.001, 50.0), (None, None));
|
||||
assert_eq!(held(&d, 50.0, 200.001), (None, None));
|
||||
// On an edge two artboards share, the artboard to the right of it or below it holds the
|
||||
// point, in either list order, except at the end of the edge that lies on that artboard's
|
||||
// bottom or right edge.
|
||||
assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1)));
|
||||
assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2)));
|
||||
// The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top
|
||||
// right corner: the first holds the point, or the last when `topmost`.
|
||||
assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner");
|
||||
d.artboards.swap(0, 1);
|
||||
assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0)));
|
||||
// Where two artboards overlap, the topmost holds the point, or the first one when not
|
||||
// `topmost`.
|
||||
let mut inner = d.artboards[1].clone();
|
||||
inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0);
|
||||
d.artboards.push(inner);
|
||||
assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1)));
|
||||
assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_paths() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Live Paint groups' Gap Options (Object › Live Paint › Gap Options): whether paint stops at small
|
||||
//! gaps between the group's paths, how wide a gap it stops at, and the color gaps preview in.
|
||||
//! A group without options has Gap Detection off.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::node::{LAYER_COLORS, Node};
|
||||
|
||||
/// Paint Stops At: the widest gap Gap Detection closes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GapSize {
|
||||
/// Small Gaps: [`GapSize::SMALL`] points.
|
||||
#[default]
|
||||
Small,
|
||||
/// Medium Gaps: [`GapSize::MEDIUM`] points.
|
||||
Medium,
|
||||
/// Large Gaps: [`GapSize::LARGE`] points.
|
||||
Large,
|
||||
/// Custom: this many points, over 0 up to [`GapSize::MAX`].
|
||||
Custom(f64),
|
||||
}
|
||||
|
||||
impl GapSize {
|
||||
pub const SMALL: f64 = 3.0;
|
||||
pub const MEDIUM: f64 = 6.0;
|
||||
pub const LARGE: f64 = 12.0;
|
||||
/// The widest custom gap.
|
||||
pub const MAX: f64 = 72.0;
|
||||
|
||||
/// The preset sizes with their ids, smallest first.
|
||||
pub const PRESETS: [(Self, &'static str); 3] = [(Self::Small, "small"), (Self::Medium, "medium"), (Self::Large, "large")];
|
||||
|
||||
/// The widest gap it closes, in points.
|
||||
pub fn points(self) -> f64 {
|
||||
match self {
|
||||
Self::Small => Self::SMALL,
|
||||
Self::Medium => Self::MEDIUM,
|
||||
Self::Large => Self::LARGE,
|
||||
Self::Custom(pt) => pt,
|
||||
}
|
||||
}
|
||||
|
||||
/// The id of a preset size (`small`, `medium` or `large`); None for a custom size.
|
||||
pub fn id(self) -> Option<&'static str> {
|
||||
Self::PRESETS.iter().find(|(s, _)| *s == self).map(|(_, id)| *id)
|
||||
}
|
||||
|
||||
/// A preset size by its id (any case), or a custom size from a number of points over 0 up to
|
||||
/// [`GapSize::MAX`].
|
||||
pub fn parse(v: &serde_json::Value) -> Option<Self> {
|
||||
match v {
|
||||
serde_json::Value::String(id) => Self::PRESETS.iter().find(|(_, i)| i.eq_ignore_ascii_case(id.trim())).map(|(s, _)| *s),
|
||||
v => v.as_f64().filter(|pt| *pt > 0.0 && *pt <= Self::MAX).map(Self::Custom),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gap Preview Color for a group that has none set: the layer color Red.
|
||||
pub const DEFAULT_GAP_COLOR: [u8; 3] = LAYER_COLORS[1].1;
|
||||
|
||||
/// A Live Paint group's Gap Options.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GapOptions {
|
||||
/// Gap Detection: paint stops at gaps up to `size` wide.
|
||||
pub detect: bool,
|
||||
/// Paint Stops At.
|
||||
pub size: GapSize,
|
||||
/// Gap Preview Color: the color gaps show in.
|
||||
pub color: [u8; 3],
|
||||
}
|
||||
|
||||
impl Default for GapOptions {
|
||||
fn default() -> Self {
|
||||
Self { detect: false, size: GapSize::Small, color: DEFAULT_GAP_COLOR }
|
||||
}
|
||||
}
|
||||
|
||||
impl GapOptions {
|
||||
/// The widest gap paint stops at, in points; None with Gap Detection off.
|
||||
pub fn max_gap(&self) -> Option<f64> {
|
||||
self.detect.then(|| self.size.points())
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The Gap Options of a Live Paint group (the defaults when it has none set).
|
||||
pub fn gap_options(&self) -> GapOptions {
|
||||
self.extras().live_paint_gaps.as_deref().copied().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn sizes_parse_from_ids_and_points() {
|
||||
for (size, id) in GapSize::PRESETS {
|
||||
assert_eq!(GapSize::parse(&json!(id)), Some(size));
|
||||
assert_eq!(GapSize::parse(&json!(id.to_ascii_uppercase())), Some(size));
|
||||
assert_eq!(size.id(), Some(id));
|
||||
}
|
||||
assert_eq!(GapSize::parse(&json!("Medium")).map(GapSize::points), Some(6.0));
|
||||
assert_eq!(GapSize::parse(&json!("large")).map(GapSize::points), Some(12.0));
|
||||
assert_eq!(GapSize::parse(&json!(4.5)), Some(GapSize::Custom(4.5)));
|
||||
assert_eq!(GapSize::Custom(4.5).points(), 4.5);
|
||||
assert_eq!(GapSize::Custom(4.5).id(), None);
|
||||
for bad in [json!(0), json!(-1), json!(72.5), json!("tiny"), json!(null), json!(true)] {
|
||||
assert_eq!(GapSize::parse(&bad), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_round_trip_and_default_to_no_detection() {
|
||||
let mut g = Node::group(crate::NodeId(1), vec![]);
|
||||
assert_eq!(g.gap_options(), GapOptions::default());
|
||||
assert_eq!(g.gap_options().max_gap(), None);
|
||||
let set = GapOptions { detect: true, size: GapSize::Custom(5.0), color: [1, 2, 3] };
|
||||
g.extras_mut().live_paint_gaps = Some(Box::new(set));
|
||||
assert_eq!(g.gap_options().max_gap(), Some(5.0));
|
||||
let text = serde_json::to_string(&g).unwrap();
|
||||
let back: Node = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.gap_options(), set);
|
||||
let bare: GapOptions = serde_json::from_value(json!({"detect": true})).unwrap();
|
||||
assert_eq!(bare, GapOptions { detect: true, ..GapOptions::default() });
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
if let Some(e) = n.extras_box() {
|
||||
b += ALLOC + size_of::<NodeExtras>();
|
||||
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
}
|
||||
|
||||
@@ -570,6 +570,8 @@ pub struct NodeExtras {
|
||||
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
|
||||
/// filled non-zero) where this object is drawn over the group's other members.
|
||||
pub intertwine: Option<Box<PathData>>,
|
||||
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
|
||||
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -587,6 +589,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
perspective: None,
|
||||
dimension: None,
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
@@ -720,6 +723,7 @@ impl Serialize for Node {
|
||||
e.perspective.is_none(),
|
||||
e.dimension.is_none(),
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -757,6 +761,7 @@ impl Serialize for Node {
|
||||
field!("perspective", &e.perspective);
|
||||
field!("dimension", &e.dimension);
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -814,6 +819,8 @@ struct NodeWire {
|
||||
dimension: Option<Box<crate::dimension::Dimension>>,
|
||||
#[serde(default)]
|
||||
intertwine: Option<Box<PathData>>,
|
||||
#[serde(default)]
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -832,6 +839,7 @@ impl From<NodeWire> for Node {
|
||||
perspective: w.perspective,
|
||||
dimension: w.dimension,
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
@@ -1706,8 +1714,41 @@ pub struct ObjectAttributes {
|
||||
/// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub data: Vec<(String, String)>,
|
||||
/// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order
|
||||
/// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as
|
||||
/// the element's event attributes; nothing in VectorCraft runs them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub events: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event
|
||||
/// attributes that run it.
|
||||
pub const SVG_EVENTS: [&str; 19] = [
|
||||
"onfocusin",
|
||||
"onfocusout",
|
||||
"onactivate",
|
||||
"onmousedown",
|
||||
"onmouseup",
|
||||
"onclick",
|
||||
"onmouseover",
|
||||
"onmousemove",
|
||||
"onmouseout",
|
||||
"onkeydown",
|
||||
"onkeypress",
|
||||
"onkeyup",
|
||||
"onload",
|
||||
"onerror",
|
||||
"onabort",
|
||||
"onunload",
|
||||
"onzoom",
|
||||
"onresize",
|
||||
"onscroll",
|
||||
];
|
||||
|
||||
/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the
|
||||
/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another.
|
||||
pub const MAX_SVG_SCRIPTS: usize = 64;
|
||||
|
||||
/// The Attributes panel's Image Map shapes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -2022,6 +2063,7 @@ mod tests {
|
||||
e.attrs = Some(Box::default());
|
||||
e.slice = Some(Box::default());
|
||||
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
|
||||
e.live_paint_gaps = Some(Box::default());
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -2063,6 +2105,7 @@ mod tests {
|
||||
"slice",
|
||||
"bbox_angle",
|
||||
"perspective",
|
||||
"live_paint_gaps",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
|
||||
@@ -248,9 +248,20 @@ impl PatternDef {
|
||||
let ab = self.art_bounds().unwrap_or(self.tile).union(self.tile) - self.tile.origin().to_vec2();
|
||||
let (sx, sy) = self.tile_type.steps(w, h);
|
||||
// Shifts of brick/hex rows or columns are < one tile.
|
||||
let (i0, i1) = (((region.x0 - ab.x1 - w) / sx).floor() as i64 - 1, ((region.x1 - ab.x0 + w) / sx).ceil() as i64 + 1);
|
||||
let (j0, j1) = (((region.y0 - ab.y1 - h) / sy).floor() as i64 - 1, ((region.y1 - ab.y0 + h) / sy).ceil() as i64 + 1);
|
||||
if (i1 - i0).saturating_mul(j1 - j0) > 250_000 {
|
||||
// A region whose tile indices do not fit in integers (or are not finite) gives none, as does
|
||||
// one with more than 250,000 positions or none.
|
||||
let index = |v: f64| (v.abs() < 9.0e18).then_some(v as i64);
|
||||
let (Some(i0), Some(i1), Some(j0), Some(j1)) = (
|
||||
index(((region.x0 - ab.x1 - w) / sx).floor()),
|
||||
index(((region.x1 - ab.x0 + w) / sx).ceil()),
|
||||
index(((region.y0 - ab.y1 - h) / sy).floor()),
|
||||
index(((region.y1 - ab.y0 + h) / sy).ceil()),
|
||||
) else {
|
||||
return vec![];
|
||||
};
|
||||
let (i0, i1, j0, j1) = (i0 - 1, i1 + 1, j0 - 1, j1 + 1);
|
||||
let (ni, nj) = (i1.saturating_sub(i0), j1.saturating_sub(j0));
|
||||
if ni < 1 || nj < 1 || ni.saturating_mul(nj) > 250_000 {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
@@ -282,8 +293,15 @@ impl PatternDef {
|
||||
out
|
||||
}
|
||||
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
|
||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Expand.
|
||||
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
|
||||
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
|
||||
pub fn instances_in(&self, paint_xf: Affine, region: Rect) -> Vec<Node> {
|
||||
self.instances_in_with(paint_xf, region, false)
|
||||
}
|
||||
/// [`Self::instances_in`], each instance placed with `scaling` (what scales with `paint_xf`
|
||||
/// besides geometry).
|
||||
pub fn instances_in_with(&self, paint_xf: Affine, region: Rect, scaling: impl Into<crate::node::Scaling>) -> Vec<Node> {
|
||||
let scaling = scaling.into();
|
||||
let inv = paint_xf.inverse();
|
||||
let pr = inv.transform_rect_bbox(region);
|
||||
let mut out = vec![];
|
||||
@@ -291,7 +309,7 @@ impl PatternDef {
|
||||
let m = paint_xf * self.instance_xf(o);
|
||||
for a in &self.art {
|
||||
let mut n = (**a).clone();
|
||||
n.transform(m, false);
|
||||
place_art(&mut n, m, scaling);
|
||||
zero_ids(&mut n);
|
||||
out.push(n);
|
||||
}
|
||||
@@ -300,6 +318,31 @@ impl PatternDef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform pattern art `n` by `m` with `scaling`, as a pattern places its art: the opacity masks
|
||||
/// in it move with it, unlinked ones included (the canvas draws a tile's art and masks together).
|
||||
pub fn place_art(n: &mut Node, m: Affine, scaling: impl Into<crate::node::Scaling>) {
|
||||
let scaling = scaling.into();
|
||||
n.transform(m, scaling);
|
||||
place_unlinked_masks(n, m, scaling);
|
||||
}
|
||||
|
||||
/// Transform the art of the unlinked opacity masks of `n` and its descendants by `m`, the masks
|
||||
/// inside mask art included.
|
||||
fn place_unlinked_masks(n: &mut Node, m: Affine, scaling: crate::node::Scaling) {
|
||||
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
let linked = mask.linked;
|
||||
let art = Arc::make_mut(&mut mask.art);
|
||||
// A linked mask's art moved with `n`; an unlinked one's moves here.
|
||||
if !linked {
|
||||
art.transform(m, scaling);
|
||||
}
|
||||
place_unlinked_masks(art, m, scaling);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
place_unlinked_masks(Arc::make_mut(c), m, scaling);
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_ids(n: &mut Node) {
|
||||
n.id = NodeId(0);
|
||||
if let Some(ch) = n.children_mut() {
|
||||
@@ -670,6 +713,18 @@ mod tests {
|
||||
assert!(close(*d2.offsets_covering(Rect::new(0.0, 0.0, 30.0, 20.0)).last().unwrap(), Vec2::new(20.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_region_too_large_to_count_has_no_offsets() {
|
||||
// A pattern scaled by 1e-20 covers a 100 pt square with a region of 1e22 pt.
|
||||
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
assert!(def.offsets_covering(Rect::new(-1e22, -1e22, 1e22, 1e22)).is_empty());
|
||||
assert!(def.offsets_covering(Rect::new(f64::NEG_INFINITY, 0.0, f64::INFINITY, 10.0)).is_empty());
|
||||
// Far out, a region one tile wide and 1e12 pt tall still counts its rows.
|
||||
assert!(def.offsets_covering(Rect::new(1e18, 0.0, 1e18, 1e12)).is_empty());
|
||||
let tiny = Affine::scale(1e-20);
|
||||
assert!(def.instances_in(tiny, Rect::new(0.0, 0.0, 100.0, 100.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_follow_paint_xf() {
|
||||
let def = PatternDef::new("p", vec![sq(100.0, 100.0, 10.0)]);
|
||||
@@ -679,6 +734,33 @@ mod tests {
|
||||
assert!(n.iter().all(|n| n.id == NodeId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_carry_unlinked_masks_with_them() {
|
||||
let mut art = (*sq(100.0, 100.0, 10.0)).clone();
|
||||
let mut mask = crate::node::OpacityMask::new((*sq(100.0, 100.0, 5.0)).clone(), true);
|
||||
mask.linked = false;
|
||||
art.extras_mut().mask = Some(Box::new(mask));
|
||||
let def = PatternDef::new("p", vec![Arc::new(art)]);
|
||||
for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) {
|
||||
let (b, m) = (n.geometric_bounds().unwrap(), n.extras().mask.as_ref().unwrap());
|
||||
assert_eq!(m.art.geometric_bounds().unwrap().origin(), b.origin(), "the mask moves with its art");
|
||||
assert!(!m.linked, "the mask stays unlinked");
|
||||
}
|
||||
// An unlinked mask inside a linked mask's art moves too.
|
||||
let mut inner = (*sq(100.0, 100.0, 5.0)).clone();
|
||||
let mut nested = crate::node::OpacityMask::new((*sq(100.0, 100.0, 2.0)).clone(), true);
|
||||
nested.linked = false;
|
||||
inner.extras_mut().mask = Some(Box::new(nested));
|
||||
let mut art = (*sq(100.0, 100.0, 10.0)).clone();
|
||||
art.extras_mut().mask = Some(Box::new(crate::node::OpacityMask::new(inner, true)));
|
||||
let def = PatternDef::new("p", vec![Arc::new(art)]);
|
||||
for n in def.instances_in(Affine::translate((3.0, 0.0)), Rect::new(0.0, 0.0, 20.0, 10.0)) {
|
||||
let outer = n.extras().mask.as_ref().unwrap();
|
||||
let nested = outer.art.extras().mask.as_ref().unwrap();
|
||||
assert_eq!(nested.art.geometric_bounds().unwrap().origin(), n.geometric_bounds().unwrap().origin());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_offsets_count() {
|
||||
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
|
||||
|
||||
@@ -79,6 +79,59 @@ impl ImageBlob {
|
||||
sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size)
|
||||
}
|
||||
|
||||
/// The colours of a `rows` × `cols` mesh laid over the image: `(rows + 1) * (cols + 1)` straight
|
||||
/// RGBA samples, row-major, one per grid line crossing (row 0 is the top). Each is the
|
||||
/// alpha-weighted average of the area around the crossing, one grid cell wide and high (a
|
||||
/// quarter of each of the neighbouring cells), not a single pixel. A crossing over transparent
|
||||
/// pixels gets their average colour at alpha 0. `None` when `rows` or `cols` is 0 or the
|
||||
/// image can't be decoded.
|
||||
pub fn grid_samples(&self, rows: u32, cols: u32) -> Option<Vec<[u8; 4]>> {
|
||||
if rows == 0 || cols == 0 {
|
||||
return None;
|
||||
}
|
||||
let img = self.decoded()?;
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
// The pixel columns [from, to) around grid line `i` of `n`, at least one pixel wide.
|
||||
let span = |i: u32, n: u32, len: usize| {
|
||||
let at = |k: f64| (k / n as f64 * len as f64).round().clamp(0.0, len as f64) as usize;
|
||||
let (from, to) = (at(i as f64 - 0.5), at(i as f64 + 0.5));
|
||||
let from = from.min(len - 1);
|
||||
(from, to.max(from + 1))
|
||||
};
|
||||
let mut out = Vec::with_capacity((rows as usize + 1) * (cols as usize + 1));
|
||||
for r in 0..=rows {
|
||||
let (y0, y1) = span(r, rows, h);
|
||||
for c in 0..=cols {
|
||||
let (x0, x1) = span(c, cols, w);
|
||||
let (mut sum, mut plain, mut alpha, mut n) = ([0u64; 3], [0u64; 3], 0u64, 0u64);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let [pr, pg, pb, pa] = img.get_pixel(x as u32, y as u32).0;
|
||||
let a = pa as u64;
|
||||
for (k, v) in [pr, pg, pb].into_iter().enumerate() {
|
||||
sum[k] += v as u64 * a;
|
||||
plain[k] += v as u64;
|
||||
}
|
||||
alpha += a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let n = n.max(1);
|
||||
out.push(if alpha == 0 {
|
||||
let m = |k: usize| ((plain[k] + n / 2) / n) as u8;
|
||||
[m(0), m(1), m(2), 0]
|
||||
} else {
|
||||
let m = |k: usize| ((sum[k] + alpha / 2) / alpha) as u8;
|
||||
[m(0), m(1), m(2), ((alpha + n / 2) / n) as u8]
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The decoded pixels (the last image decoded is kept for the next sample).
|
||||
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
|
||||
thread_local! {
|
||||
|
||||
@@ -430,7 +430,7 @@ fn art_id() -> crate::NodeId {
|
||||
}
|
||||
|
||||
/// Visit `n` and every descendant (opacity-mask art included) mutably.
|
||||
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
|
||||
pub(crate) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
|
||||
f(n);
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
each_mut(Arc::make_mut(&mut m.art), f);
|
||||
|
||||
@@ -192,6 +192,16 @@ impl Background {
|
||||
|
||||
/// Largest bleed on any side, in points (1 inch).
|
||||
pub const MAX_BLEED: f64 = 72.0;
|
||||
/// Largest superscript or subscript size, and the farthest its baseline moves either way, in
|
||||
/// percent of the font size.
|
||||
pub const MAX_SCRIPT: f64 = 500.0;
|
||||
/// Largest synthesized small capitals size, in percent of the font size.
|
||||
pub const MAX_SMALL_CAPS: f64 = 200.0;
|
||||
|
||||
/// Returns `bleed` with each side kept to 0–[`MAX_BLEED`] points; a side that isn't finite becomes 0.
|
||||
pub fn bleed_in_range(bleed: [f64; 4]) -> [f64; 4] {
|
||||
bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// File → Document Setup.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -274,6 +284,18 @@ impl DocSetup {
|
||||
pub fn flattener(&self) -> &str {
|
||||
self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER)
|
||||
}
|
||||
/// Moves the bleed and the type proportions into the ranges Document Setup takes, each to the
|
||||
/// nearer end: the bleed by [`bleed_in_range`], superscript and subscript sizes to
|
||||
/// 1–[`MAX_SCRIPT`] and positions to ±[`MAX_SCRIPT`], the small caps size to
|
||||
/// 1–[`MAX_SMALL_CAPS`].
|
||||
pub fn keep_in_range(&mut self) {
|
||||
self.bleed = bleed_in_range(self.bleed);
|
||||
for m in [&mut self.superscript, &mut self.subscript] {
|
||||
m.size = m.size.clamp(1.0, MAX_SCRIPT);
|
||||
m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT);
|
||||
}
|
||||
self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3
|
||||
//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the
|
||||
//! centre stretch
|
||||
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
|
||||
//!
|
||||
//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left
|
||||
//! and top edges and from the right and bottom edges, so they survive resizing the art. Like
|
||||
//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas.
|
||||
//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Document unknown key: `{ symbolName: [left, top, right, bottom] }`.
|
||||
pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice";
|
||||
|
||||
/// The four 9-slice guides as fractions of the symbol's natural size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SymbolNineSlice {
|
||||
/// Left guide, from the left edge.
|
||||
pub left: f64,
|
||||
/// Top guide, from the top edge.
|
||||
pub top: f64,
|
||||
/// Right guide, from the right edge.
|
||||
pub right: f64,
|
||||
/// Bottom guide, from the bottom edge.
|
||||
pub bottom: f64,
|
||||
}
|
||||
|
||||
impl Default for SymbolNineSlice {
|
||||
/// The centre third of each axis is the stretchy cell.
|
||||
fn default() -> Self {
|
||||
let t = 1.0 / 3.0;
|
||||
Self { left: t, top: t, right: t, bottom: t }
|
||||
}
|
||||
}
|
||||
|
||||
impl SymbolNineSlice {
|
||||
/// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the
|
||||
/// two on each axis leave a gap between them.
|
||||
pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option<Self> {
|
||||
let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
|
||||
(ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom })
|
||||
}
|
||||
|
||||
pub fn to_value(self) -> Value {
|
||||
json!([self.left, self.top, self.right, self.bottom])
|
||||
}
|
||||
|
||||
pub fn from_value(v: &Value) -> Option<Self> {
|
||||
let a = v.as_array()?;
|
||||
let n = |i: usize| a.get(i).and_then(Value::as_f64);
|
||||
if a.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
Self::new(n(0)?, n(1)?, n(2)?, n(3)?)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ pub struct CharStyle {
|
||||
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font_version: Option<String>,
|
||||
/// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag
|
||||
/// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of
|
||||
/// each axis they name; the other axes keep the style's settings. Empty: the style as it is.
|
||||
#[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub font_axes: std::collections::BTreeMap<String, f64>,
|
||||
/// Size in points.
|
||||
pub size: f64,
|
||||
/// Leading in points; None = Auto (120% of size).
|
||||
@@ -174,6 +179,18 @@ pub struct ScriptMetrics {
|
||||
|
||||
impl ScriptMetrics {
|
||||
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
|
||||
/// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to
|
||||
/// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside
|
||||
/// its range becomes the nearer end.
|
||||
pub fn in_range(self) -> Self {
|
||||
Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for
|
||||
/// `smallCapsSize`. A size outside it becomes the nearer end.
|
||||
fn small_caps_in_range(size: f64) -> f64 {
|
||||
size.clamp(1.0, 200.0)
|
||||
}
|
||||
|
||||
impl Default for ScriptMetrics {
|
||||
@@ -210,6 +227,14 @@ impl CharPosition {
|
||||
CharPosition::Subscript(_) => "subscript",
|
||||
}
|
||||
}
|
||||
/// Returns this position with its proportions kept by [`ScriptMetrics::in_range`].
|
||||
pub fn in_range(self) -> Self {
|
||||
match self {
|
||||
CharPosition::Normal => CharPosition::Normal,
|
||||
CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()),
|
||||
CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ten() -> f64 {
|
||||
@@ -231,6 +256,7 @@ impl Default for CharStyle {
|
||||
font_family: "Source Sans 3".into(),
|
||||
font_style: "Regular".into(),
|
||||
font_version: None,
|
||||
font_axes: std::collections::BTreeMap::new(),
|
||||
size: 12.0,
|
||||
leading: None,
|
||||
tracking: 0.0,
|
||||
@@ -267,6 +293,20 @@ impl CharStyle {
|
||||
pub fn effective_leading(&self) -> f64 {
|
||||
self.leading.unwrap_or(self.size * 1.2)
|
||||
}
|
||||
/// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size
|
||||
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
|
||||
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
|
||||
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
|
||||
/// baseline shift that is not a number becomes 0.
|
||||
pub fn keep_dimensions_in_range(&mut self) {
|
||||
let default = Self::default();
|
||||
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
|
||||
self.size = within(self.size, default.size);
|
||||
self.h_scale = within(self.h_scale, default.h_scale);
|
||||
self.v_scale = within(self.v_scale, default.v_scale);
|
||||
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
|
||||
self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
|
||||
}
|
||||
/// Do the characters draw a stroke (a paint and a positive weight)?
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
!self.stroke.is_none() && self.stroke_width > 0.0
|
||||
@@ -327,6 +367,60 @@ impl CharStyle {
|
||||
pub fn basic_appearance(&self) -> Appearance {
|
||||
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
|
||||
}
|
||||
/// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and
|
||||
/// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`.
|
||||
pub fn keep_scripts_in_range(&mut self) {
|
||||
self.position = self.position.in_range();
|
||||
self.small_caps = self.small_caps.map(small_caps_in_range);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup`
|
||||
/// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph
|
||||
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
|
||||
/// back (opacity-mask art included), in graphs' label styles and in the character styles. It
|
||||
/// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`].
|
||||
pub fn keep_scripts_in_range(&mut self) {
|
||||
let mut fix = |n: &mut crate::Node| {
|
||||
if let crate::NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
r.style.keep_scripts_in_range();
|
||||
}
|
||||
}
|
||||
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
let l = &mut g.label_styles;
|
||||
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
|
||||
st.keep_scripts_in_range();
|
||||
}
|
||||
}
|
||||
};
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
|
||||
}
|
||||
// Character styles store the attributes they set by their serialized names.
|
||||
for def in self.char_styles.iter_mut() {
|
||||
if let Some(v) = def.attrs.get_mut("position")
|
||||
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
|
||||
&& p.in_range() != p
|
||||
{
|
||||
*v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null);
|
||||
}
|
||||
if let Some(v) = def.attrs.get_mut("small_caps")
|
||||
&& let Some(size) = v.as_f64()
|
||||
&& small_caps_in_range(size) != size
|
||||
{
|
||||
*v = small_caps_in_range(size).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab stop alignment (Tabs panel).
|
||||
@@ -452,6 +546,26 @@ pub struct ParaStyle {
|
||||
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
|
||||
/// line.
|
||||
pub kinsoku: Kinsoku,
|
||||
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
|
||||
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
|
||||
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
|
||||
pub roman_hanging: bool,
|
||||
}
|
||||
|
||||
impl ParaStyle {
|
||||
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
|
||||
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
|
||||
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
|
||||
/// not a number becomes 0.
|
||||
pub fn keep_spacing_in_range(&mut self) {
|
||||
let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) };
|
||||
for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] {
|
||||
*v = within(*v, -crate::MAX_COORD, crate::MAX_COORD);
|
||||
}
|
||||
for t in &mut self.tabs {
|
||||
t.position = within(t.position, 0.0, 100_000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
|
||||
@@ -493,6 +607,8 @@ struct ParaStyleFile {
|
||||
burasagari: Burasagari,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
kinsoku: Kinsoku,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
roman_hanging: bool,
|
||||
}
|
||||
|
||||
impl From<ParaStyle> for ParaStyleFile {
|
||||
@@ -519,6 +635,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
@@ -539,6 +656,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -562,6 +680,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
..
|
||||
} = f;
|
||||
Self {
|
||||
@@ -581,6 +700,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1067,6 +1187,12 @@ pub struct TextObject {
|
||||
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wrap: Vec<WrapShape>,
|
||||
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
|
||||
/// sides of letters such as A and W at a line's start or end hang outside the margin there
|
||||
/// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off
|
||||
/// for new type and for documents from before it, and saved only when on.
|
||||
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub optical_margin: bool,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
@@ -1132,6 +1258,7 @@ impl TextObject {
|
||||
path_align: PathAlign::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
@@ -1348,6 +1475,87 @@ impl TextObject {
|
||||
});
|
||||
self.cached_baselines.iter().copied().chain(first)
|
||||
}
|
||||
/// Keeps every run's size, leading, baseline shift and scales in range
|
||||
/// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab
|
||||
/// stops ([`ParaStyle::keep_spacing_in_range`]).
|
||||
pub fn keep_type_values_in_range(&mut self) {
|
||||
for r in &mut self.runs {
|
||||
r.style.keep_dimensions_in_range();
|
||||
}
|
||||
for p in self.para_styles_mut() {
|
||||
p.keep_spacing_in_range();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// Keeps the character dimensions, indents, paragraph spacing and tab stops in range
|
||||
/// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph
|
||||
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
|
||||
/// back (opacity-mask art included), and the same attributes of the character and paragraph
|
||||
/// styles. It recurses through the art: call it on a document no deeper than
|
||||
/// [`crate::depth::MAX_DEPTH`].
|
||||
pub fn keep_type_values_in_range(&mut self) {
|
||||
let mut fix = |n: &mut crate::Node| {
|
||||
if let crate::NodeKind::Text(t) = &mut n.kind {
|
||||
t.keep_type_values_in_range();
|
||||
}
|
||||
};
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
|
||||
}
|
||||
// Styles store the attributes they set by their serialized names (a number, or null for
|
||||
// Auto leading); a number is rewritten only when it changes.
|
||||
for def in self.char_styles.iter_mut() {
|
||||
let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64);
|
||||
let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() };
|
||||
st.size = num("size").unwrap_or(st.size);
|
||||
st.h_scale = num("h_scale").unwrap_or(st.h_scale);
|
||||
st.v_scale = num("v_scale").unwrap_or(st.v_scale);
|
||||
st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift);
|
||||
st.keep_dimensions_in_range();
|
||||
let kept = [
|
||||
("size", Some(st.size)),
|
||||
("h_scale", Some(st.h_scale)),
|
||||
("v_scale", Some(st.v_scale)),
|
||||
("baseline_shift", Some(st.baseline_shift)),
|
||||
("leading", st.leading),
|
||||
];
|
||||
for (k, v) in kept {
|
||||
if let Some(old) = def.attrs.get_mut(k)
|
||||
&& old.is_number()
|
||||
&& old.as_f64() != v
|
||||
{
|
||||
*old = v.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
for def in self.para_styles.iter_mut() {
|
||||
for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] {
|
||||
if let Some(v) = def.attrs.get_mut(k)
|
||||
&& let Some(x) = v.as_f64()
|
||||
&& x.abs() > crate::MAX_COORD
|
||||
{
|
||||
*v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into();
|
||||
}
|
||||
}
|
||||
for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() {
|
||||
if let Some(v) = t.get_mut("position")
|
||||
&& let Some(x) = v.as_f64()
|
||||
&& !(0.0..=100_000.0).contains(&x)
|
||||
{
|
||||
*v = x.clamp(0.0, 100_000.0).into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -116,6 +116,10 @@ pub struct Variables {
|
||||
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active_dataset: Option<String>,
|
||||
/// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can
|
||||
/// still be bound to them and unbound, and datasets still change and apply.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
@@ -184,20 +188,26 @@ impl Document {
|
||||
self.variables.bindings = bindings;
|
||||
}
|
||||
|
||||
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
|
||||
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
|
||||
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
|
||||
/// able to show it again, and a capture must record it hidden.
|
||||
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
|
||||
/// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't
|
||||
/// count, as that is what a visibility variable drives.
|
||||
pub fn variable_target(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
|
||||
}
|
||||
|
||||
/// Whether a data set reads and rewrites the bound object `id`: it is in the document and
|
||||
/// neither it nor anything above it is locked. Hidden objects count, and so do objects in a
|
||||
/// hidden layer or group.
|
||||
pub fn dataset_target(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked)))
|
||||
}
|
||||
|
||||
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
|
||||
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
|
||||
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
|
||||
/// link path yet (an embedded image), or is locked or in a locked layer or group (the
|
||||
/// document's value is whatever it was left at).
|
||||
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
|
||||
if !self.variable_target(id) {
|
||||
if !self.dataset_target(id) {
|
||||
return None;
|
||||
}
|
||||
let node = self.node(id)?;
|
||||
|
||||
@@ -490,4 +490,20 @@ mod tests {
|
||||
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
|
||||
assert!(out.appearance.items[1].effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_recolours_the_points_of_a_freeform_gradient() {
|
||||
use vectorcraft_doc::color::{Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint};
|
||||
let mut gp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() });
|
||||
let pt = |x, c| FreeformPoint::new(kurbo::Point::new(x, 5.0), c);
|
||||
gp.set_freeform(Freeform { points: vec![pt(2.0, Color::rgb(1.0, 0.0, 0.0)), pt(8.0, Color::rgb(0.0, 0.0, 1.0))], ..Default::default() });
|
||||
let fill = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0);
|
||||
let mut p = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill);
|
||||
p.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
|
||||
let out = adjust(&p, None, &mut |_, _| None).unwrap();
|
||||
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient fill") };
|
||||
// The points are what a freeform gradient draws from, not its stops.
|
||||
let hexes: Vec<String> = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect();
|
||||
assert_eq!(hexes, ["#00ff00", "#ff0000"]);
|
||||
}
|
||||
}
|
||||
@@ -513,7 +513,8 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an
|
||||
/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path
|
||||
/// painted by that fill and every visible stroke without a brush its filled outline in the
|
||||
/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as
|
||||
/// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke:
|
||||
/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
@@ -543,8 +544,28 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
clear_item_effects(&mut it);
|
||||
pieces.push(match &it {
|
||||
AppearanceItem::Stroke(st) if st.brush.is_none() => {
|
||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) };
|
||||
piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill))
|
||||
let outline = crate::stroke::outline_region(&ig, rule, st);
|
||||
match st.path_gradient() {
|
||||
// A gradient along or across the stroke: its gradient meshes clipped to the outline.
|
||||
Some(g) => {
|
||||
let mut clip = Node::path(n.id, outline, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let meshes = crate::stroke::gradient_meshes(&ig.to_bezpath(), rule, st, &g.gradient);
|
||||
let meshes = meshes.into_iter().map(|m| Node::new(n.id, NodeKind::Mesh(m)));
|
||||
let children = std::iter::once(clip).chain(meshes).map(Arc::new).collect();
|
||||
let mut group = Node::new(n.id, NodeKind::Group { children, clip: true });
|
||||
(group.opacity, group.blend) = (st.opacity, st.blend);
|
||||
group.appearance.effects = st.effects.clone();
|
||||
Arc::new(group)
|
||||
}
|
||||
None => {
|
||||
let effects = st.effects.clone();
|
||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) };
|
||||
piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill))
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => piece(ig, rule, it),
|
||||
});
|
||||
@@ -636,6 +657,36 @@ mod tests {
|
||||
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_stroke_keeps_its_raster_effects() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
|
||||
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
|
||||
s.effects.push(fx("stylize.dropShadow", json!({})));
|
||||
}
|
||||
let m = bake_appearance(&n).unwrap();
|
||||
let ch = m.children().unwrap();
|
||||
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
|
||||
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
|
||||
s.width = 10.0;
|
||||
s.paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
|
||||
s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along;
|
||||
s.opacity = 0.5;
|
||||
}
|
||||
let m = bake_appearance(&n).unwrap();
|
||||
let ch = m.children().unwrap();
|
||||
let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) };
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(children.len() > 1 && children[1..].iter().all(|c| matches!(c.kind, NodeKind::Mesh(_))), "gradient meshes");
|
||||
assert_eq!(ch[1].opacity, 0.5);
|
||||
}
|
||||
|
||||
/// Black 24 pt "Type" with its baseline at (20, 60).
|
||||
fn type_node(id: u64) -> Node {
|
||||
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
|
||||
|
||||
+42
-12
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, Rasterize, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
@@ -37,6 +37,7 @@ mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod lighting;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
@@ -68,7 +69,7 @@ pub use group::{
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
@@ -150,6 +151,7 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"texture.patchwork" => always(&["squareSize"]),
|
||||
"texture.stainedGlass" => always(&["cellSize"]),
|
||||
"sketch.halftonePattern" => always(&["size"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -202,29 +204,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
"Extrude & Bevel…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
INFLATE,
|
||||
"Inflate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
ROTATE,
|
||||
"Rotate…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{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), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
@@ -336,6 +338,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
),
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r(
|
||||
"artistic.cutout",
|
||||
"Cutout…",
|
||||
ARTISTIC,
|
||||
"{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.filmGrain",
|
||||
"Film Grain…",
|
||||
@@ -343,6 +352,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}),
|
||||
),
|
||||
r(
|
||||
"artistic.neonGlow",
|
||||
"Neon Glow…",
|
||||
ARTISTIC,
|
||||
"{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}),
|
||||
),
|
||||
r(
|
||||
"artistic.posterEdges",
|
||||
"Poster Edges…",
|
||||
ARTISTIC,
|
||||
"{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
|
||||
json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}),
|
||||
),
|
||||
r(
|
||||
"blur.radial",
|
||||
"Radial Blur…",
|
||||
@@ -354,7 +377,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"blur.smart",
|
||||
"Smart Blur…",
|
||||
BLUR,
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's; the white and the black keep each pixel's coverage, and the radius changes only the blur",
|
||||
"{radius: pt 0.1..100 (3), threshold: levels 0.1..100 (25; only colours closer than this blur together, so edges stay sharp), quality: \"low\"|\"medium\"|\"high\" (\"medium\"; 5, 7 or 9 samples across), blurMode: \"normal\"|\"edgeOnly\"|\"overlayEdge\" (\"normal\"; edgeOnly: the edges in white on black, overlayEdge: the edges in white over the blur)}. A pixel is on an edge when one of the pixels the search reads within the radius (all of them for a small radius, a grid of samples for a wide one) or one of its four nearest pixels differs from it by more than the threshold in a channel and its channels (alpha included) add up to at least that pixel's, so a step gives a line about as wide as the radius, and at least one pixel wide, on the side with the larger sum; the white and the black keep each pixel's coverage",
|
||||
json!({"radius": 3.0, "threshold": 25.0, "quality": "medium", "blurMode": "normal"}),
|
||||
),
|
||||
r(
|
||||
@@ -614,6 +637,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it",
|
||||
json!({}),
|
||||
));
|
||||
v.push(r(
|
||||
RASTERIZE,
|
||||
"Rasterize…",
|
||||
&["Effect"],
|
||||
"{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes",
|
||||
json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}),
|
||||
));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -763,7 +793,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
|
||||
|
||||
/// Is `id` a raster (painted) effect?
|
||||
pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE)
|
||||
|| PIXEL_EFFECTS.contains(&id)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Shared conversion and vector paint shading for the 3D effects' directional lights.
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer};
|
||||
|
||||
fn parse(v: &Value) -> Option<Color> {
|
||||
let s = v.as_str()?;
|
||||
// Match the command's opaque RGB contract, before any parsing or allocation.
|
||||
(s.len() == 7 && s.starts_with('#') && s.as_bytes().iter().skip(1).all(u8::is_ascii_hexdigit)).then(|| Color::from_hex(s)).flatten()
|
||||
}
|
||||
|
||||
pub(crate) fn light_color(p: &Value) -> [f64; 3] {
|
||||
p.get("lightColor").and_then(parse).unwrap_or(Color::WHITE).to_rgb_uncalibrated().map(f64::from)
|
||||
}
|
||||
|
||||
pub(crate) fn validate(p: &Value) -> Result<(), String> {
|
||||
if p.get("lightColor").is_some_and(|v| parse(v).is_none()) {
|
||||
return Err("lightColor must be an opaque RGB color in #rrggbb form".into());
|
||||
}
|
||||
if let Some(list) = p.get("lights").filter(|v| !v.is_null()) {
|
||||
parse_lights(list)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn lights(p: &Value) -> Option<vectorcraft_three_d::Lights> {
|
||||
p.get("lights").filter(|v| !v.is_null()).and_then(|v| parse_lights(v).ok())
|
||||
}
|
||||
|
||||
fn parse_lights(v: &Value) -> Result<vectorcraft_three_d::Lights, String> {
|
||||
use vectorcraft_three_d::{Light, Lights, MAX_LIGHTS};
|
||||
let list = v.as_array().filter(|v| !v.is_empty() && v.len() <= MAX_LIGHTS).ok_or("lights must contain one to ten lights")?;
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for item in list {
|
||||
let item = item.as_object().ok_or("each light must be an object")?;
|
||||
if item.keys().any(|key| !["color", "intensity", "rotation", "height", "softness", "behind"].contains(&key.as_str())) {
|
||||
return Err("unknown light setting: use color, intensity, rotation, height, softness or behind".into());
|
||||
}
|
||||
let defaults = Light::default();
|
||||
let number = |key: &str, default: f64, min: f64, max: f64| -> Result<f64, String> {
|
||||
match item.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && *n >= min && *n <= max)
|
||||
.ok_or_else(|| format!("light {key} must be a number from {min} to {max}")),
|
||||
}
|
||||
};
|
||||
let color = match item.get("color") {
|
||||
None => [1.0; 3],
|
||||
Some(v) => parse(v).ok_or("light color must use opaque #rrggbb")?.to_rgb_uncalibrated().map(f64::from),
|
||||
};
|
||||
let behind = item.get("behind").map_or(Ok(false), |v| v.as_bool().ok_or("light behind must be true or false"))?;
|
||||
out.push(Light {
|
||||
color,
|
||||
behind,
|
||||
intensity: number("intensity", defaults.intensity * 100.0, 0.0, 100.0)? / 100.0,
|
||||
rotation: number("rotation", defaults.rotation, -180.0, 180.0)?,
|
||||
height: number("height", defaults.height, 0.0, 90.0)?,
|
||||
softness: number("softness", defaults.softness * 100.0, 0.0, 100.0)? / 100.0,
|
||||
});
|
||||
}
|
||||
Lights::new(&out)
|
||||
}
|
||||
|
||||
pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::rgb(r * rgb[0] as f32, g * rgb[1] as f32, b * rgb[2] as f32)
|
||||
}
|
||||
|
||||
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
|
||||
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(p))
|
||||
}
|
||||
|
||||
/// Multiply two independently placed vector gradients before applying the face's coverage
|
||||
/// once. Separately antialiasing the source and light along each triangle edge leaves seams.
|
||||
pub(crate) fn gradient_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::LightGradient,
|
||||
) -> vectorcraft_doc::Node {
|
||||
composed_art(id, path, paint, Ramp::Single(light), 0.75)
|
||||
}
|
||||
|
||||
pub(crate) fn rgb_gradient_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::RgbGradient,
|
||||
) -> vectorcraft_doc::Node {
|
||||
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
|
||||
}
|
||||
|
||||
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
|
||||
pub(crate) fn facet_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::RgbGradient,
|
||||
seam: f64,
|
||||
) -> vectorcraft_doc::Node {
|
||||
let [r, g, b] = light.channels;
|
||||
if r == g && g == b {
|
||||
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
|
||||
if let Some(color) = paint.color() {
|
||||
let paint = smooth_paint(light, color);
|
||||
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
return vectorcraft_doc::Node::path(
|
||||
id,
|
||||
path.clone(),
|
||||
vectorcraft_doc::Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
return composed_art(id, path, paint, Ramp::Single(light), seam);
|
||||
}
|
||||
composed_art(id, path, paint, Ramp::Rgb(light), seam)
|
||||
}
|
||||
|
||||
/// Composite smooth solid-fill facets with one clip and three channel groups per surface.
|
||||
/// Black channel backdrops keep shared edges opaque before the final coverage is applied.
|
||||
pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node {
|
||||
use kurbo::Shape;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
|
||||
|
||||
let mut boundary = BezPath::new();
|
||||
let mut bounds = None;
|
||||
for face in facets {
|
||||
let path = face.path();
|
||||
if let Some(b) = path.bounds() {
|
||||
bounds = Some(bounds.map_or(b, |r: Rect| r.union(b)));
|
||||
}
|
||||
let mut contour = path.to_bezpath();
|
||||
if contour.area() < 0.0 {
|
||||
contour = contour.reverse_subpaths();
|
||||
}
|
||||
boundary.extend(contour.iter());
|
||||
}
|
||||
let clip = Node::path(id, PathData::from_bezpath(&boundary), Appearance::default());
|
||||
let bounds = bounds.unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
||||
let channels = (0..3)
|
||||
.map(|channel| {
|
||||
let mut color = [0.0; 3];
|
||||
if let Some(value) = color.get_mut(channel) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))];
|
||||
children.extend(facets.iter().map(|face| {
|
||||
let paint = if let Some(ramp) = face.rgb_gradient {
|
||||
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
|
||||
} else {
|
||||
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
|
||||
};
|
||||
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
Arc::new(Node::path(
|
||||
id,
|
||||
face.path(),
|
||||
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() },
|
||||
))
|
||||
}));
|
||||
let mut group = Node::group(id, children);
|
||||
group.isolate = true;
|
||||
group.blend = BlendMode::Screen;
|
||||
Arc::new(group)
|
||||
})
|
||||
.collect();
|
||||
let mut light = Node::group(id, channels);
|
||||
light.isolate = true;
|
||||
light.blend = BlendMode::Multiply;
|
||||
let mut colors = Node::group(id, vec![Arc::new(cover(paint.clone())), Arc::new(light)]);
|
||||
colors.isolate = true;
|
||||
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true })
|
||||
}
|
||||
|
||||
enum Ramp {
|
||||
Single(vectorcraft_three_d::LightGradient),
|
||||
Rgb(vectorcraft_three_d::RgbGradient),
|
||||
}
|
||||
|
||||
/// Separate a gradient's color and coverage so a tessellated surface can apply its opacity
|
||||
/// once, instead of building opacity at the overlapping seam strokes of adjacent faces.
|
||||
pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option<Paint>) {
|
||||
let mut base = paint.clone();
|
||||
let mut mask = paint.clone();
|
||||
let mut transparent = false;
|
||||
if let (Paint::Gradient(b), Paint::Gradient(m)) = (&mut base, &mut mask) {
|
||||
b.swatch = None;
|
||||
m.swatch = None;
|
||||
for (b, m) in b.gradient.stops.iter_mut().zip(&mut m.gradient.stops) {
|
||||
transparent |= b.opacity != 1.0;
|
||||
b.opacity = 1.0;
|
||||
m.color = Color::WHITE;
|
||||
m.swatch = None;
|
||||
m.tint = 1.0;
|
||||
}
|
||||
if let (Some(b), Some(m)) = (&mut b.freeform, &mut m.freeform) {
|
||||
for (b, m) in b.points.iter_mut().zip(&mut m.points) {
|
||||
transparent |= b.opacity != 1.0;
|
||||
b.opacity = 1.0;
|
||||
m.color = Color::WHITE;
|
||||
}
|
||||
}
|
||||
}
|
||||
(base, transparent.then_some(mask))
|
||||
}
|
||||
|
||||
fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node {
|
||||
use kurbo::Shape;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
let (base, mask) = opaque_paint(paint);
|
||||
// Clip once to the union of this single-contour face and its round seam stroke.
|
||||
// Align their winding so the nonzero fill adds coverage instead of cancelling it.
|
||||
let mut boundary = path.to_bezpath();
|
||||
let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
|
||||
if boundary.area() * footprint.area() < 0.0 {
|
||||
boundary = boundary.reverse_subpaths();
|
||||
}
|
||||
footprint.extend(boundary.iter());
|
||||
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
|
||||
// Opaque rectangles cover the complete seam stroke, without separately antialiasing
|
||||
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks.
|
||||
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
||||
let mut illumination = match light {
|
||||
Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)),
|
||||
Ramp::Rgb(light) => {
|
||||
let channels = light
|
||||
.channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(channel, gradient)| {
|
||||
let mut color = [0.0; 3];
|
||||
if let Some(value) = color.get_mut(channel) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let mut layer = cover(smooth_paint(vectorcraft_three_d::LightGradient { diffuse: *gradient, ambient: 0.0, color }, Color::WHITE));
|
||||
// Screen adds disjoint R, G and B channels exactly; no light is clamped
|
||||
// separately. Each ramp already holds the sum of all directional lights.
|
||||
layer.blend = BlendMode::Screen;
|
||||
Arc::new(layer)
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(id, channels);
|
||||
group.isolate = true;
|
||||
group
|
||||
}
|
||||
};
|
||||
illumination.blend = BlendMode::Multiply;
|
||||
let mut colors = Node::group(id, vec![Arc::new(cover(base)), Arc::new(illumination)]);
|
||||
colors.isolate = true;
|
||||
// Keep isolation inside the clip: the clip then applies to the finished colors,
|
||||
// rather than separately antialiasing each of the two gradient drawings.
|
||||
let mut face = Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true });
|
||||
if let Some(mask) = mask {
|
||||
face.extras_mut().mask = Some(Box::new(OpacityMask::new(cover(mask), true)));
|
||||
}
|
||||
face
|
||||
}
|
||||
|
||||
pub(crate) fn fills(paint: &Paint, rgb: [f64; 3], brightness: f64, colored: bool) -> Vec<AppearanceItem> {
|
||||
let shade = |c: Color| shade(c, rgb);
|
||||
if let Some(c) = paint.color() {
|
||||
return vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(shade(c))))];
|
||||
}
|
||||
// Keep the white-light gradient treatment identical to existing documents. Colored light
|
||||
// shades the ramp itself, keeping placement, stops, midpoint and opacity as vector data.
|
||||
if matches!(paint, Paint::Gradient(_)) && colored {
|
||||
let mut shaded = paint.clone();
|
||||
vectorcraft_doc::recolor::map_paint(&mut shaded, &shade);
|
||||
if let Paint::Gradient(g) = &mut shaded {
|
||||
g.swatch = None;
|
||||
}
|
||||
return vec![AppearanceItem::Fill(FillLayer::new(shaded))];
|
||||
}
|
||||
// Pattern tiles remain referenced artwork. Retain their existing neutral shadow
|
||||
// approximation; tinting arbitrary pattern contents needs document-level evaluation.
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - brightness) as f32;
|
||||
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Fill(shadow)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
#[test]
|
||||
fn command_contract_is_strict_and_loaded_invalid_colors_fall_back_to_white() {
|
||||
assert_eq!(light_color(&json!({})), [1.0; 3]);
|
||||
assert_eq!(light_color(&json!({"lightColor":"#FF0000"})), [1.0, 0.0, 0.0]);
|
||||
for v in [json!("#ff0"), json!("#ffffff80"), json!("blue"), json!("#gg0000"), json!(null), json!([1, 0, 0])] {
|
||||
let p = json!({"lightColor":v});
|
||||
assert!(validate(&p).is_err(), "{p}");
|
||||
assert_eq!(light_color(&p), [1.0; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_loaded_lists_keep_the_legacy_light_and_valid_lists_override_it() {
|
||||
for value in [json!([]), json!(vec![json!({}); 11]), json!([false]), json!([{"height": 91}]), json!([{"color":"#ff0"}])] {
|
||||
let p = json!({"lights": value, "lightColor":"#ff0000"});
|
||||
assert!(lights(&p).is_none());
|
||||
assert_eq!(light_color(&p), [1.0, 0.0, 0.0]);
|
||||
assert!(validate(&p).is_err());
|
||||
}
|
||||
let p = json!({"lights":[{}, {"color":"#0000ff","intensity":40}], "lightColor":"#ff0000"});
|
||||
let list = lights(&p).unwrap();
|
||||
assert_eq!(list.iter().count(), 2);
|
||||
assert_eq!(*list.iter().next().unwrap(), vectorcraft_three_d::Light::default());
|
||||
assert_eq!(list.iter().nth(1).unwrap().color, [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_gradient_keeps_placement_alpha_and_midpoints() {
|
||||
let mut stop = GradientStop::new(0.0, Color::WHITE);
|
||||
stop.opacity = 0.4;
|
||||
stop.midpoint = 0.3;
|
||||
let g = GradientPaint {
|
||||
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
|
||||
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
|
||||
angle: 0.0,
|
||||
swatch: None,
|
||||
freeform: None,
|
||||
};
|
||||
let paint = Paint::Gradient(Box::new(g.clone()));
|
||||
let items = fills(&paint, [1.0, 0.25, 0.25], 1.0, true);
|
||||
let Paint::Gradient(shaded) = items[0].paint() else { panic!("vector gradient") };
|
||||
assert_eq!(shaded.geom, g.geom);
|
||||
assert_eq!(shaded.gradient.stops[0].color.to_rgb(), [1.0, 0.25, 0.25]);
|
||||
assert_eq!(shaded.gradient.stops[0].opacity, 0.4);
|
||||
assert_eq!(shaded.gradient.stops[0].midpoint, 0.3);
|
||||
assert_eq!(shaded.gradient.stops[1].color, Color::BLACK);
|
||||
assert_eq!(paint, Paint::Gradient(Box::new(g)));
|
||||
let white = fills(&paint, [0.4; 3], 0.4, false);
|
||||
assert_eq!(white[0].paint(), &paint, "white retains the previous gradient treatment");
|
||||
assert_eq!(white.len(), 2);
|
||||
let unlit = fills(&paint, [0.25; 3], 0.25, true);
|
||||
assert_eq!(unlit.len(), 1, "neutral ambient under colored light still tints once");
|
||||
let mut freeform = if let Paint::Gradient(g) = paint { g } else { panic!("gradient") };
|
||||
freeform.gradient.kind = GradientKind::Freeform;
|
||||
freeform.gradient.stops = vec![GradientStop::new(0.0, Color::BLACK)];
|
||||
freeform.swatch = Some("Original ramp".into());
|
||||
freeform.freeform = Some(vectorcraft_doc::color::Freeform {
|
||||
points: vec![vectorcraft_doc::color::FreeformPoint::new(Point::new(12.0, 18.0), Color::WHITE)],
|
||||
..Default::default()
|
||||
});
|
||||
let original = Paint::Gradient(freeform);
|
||||
let items = fills(&original, [1.0, 0.25, 0.25], 1.0, true);
|
||||
let Paint::Gradient(shaded) = items[0].paint() else { panic!("freeform gradient") };
|
||||
let point = shaded.freeform.as_ref().unwrap().points[0];
|
||||
assert_eq!(point.at, Point::new(12.0, 18.0));
|
||||
assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]);
|
||||
assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Effect › Artistic. Original filters built from public behaviour documentation:
|
||||
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). Original filters built from
|
||||
//! public behaviour documentation:
|
||||
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
|
||||
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
|
||||
//! These are visual approximations, not reproductions of a proprietary algorithm.
|
||||
|
||||
use super::PixelSpace;
|
||||
use super::edges::sobel;
|
||||
use super::pixelate::{Place, cell, rand};
|
||||
use super::texture::{luma, premultiply, straight};
|
||||
use super::{PixelSpace, blur_plane};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
const SALT_FILM: u64 = 256;
|
||||
@@ -134,3 +136,135 @@ pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSp
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// The coverage-weighted Gaussian softening of the planes `planes` (premultiplied by `alpha`) by
|
||||
/// `sigma` pixels: each divided by the softened coverage, so transparency doesn't darken edges.
|
||||
fn soften(planes: &mut [Vec<f32>], alpha: &[f32], w: usize, h: usize, sigma: f64) {
|
||||
let mut a = alpha.to_vec();
|
||||
blur_plane(&mut a, w, h, sigma);
|
||||
for plane in planes {
|
||||
for (v, c) in plane.iter_mut().zip(alpha) {
|
||||
*v *= c;
|
||||
}
|
||||
blur_plane(plane, w, h, sigma);
|
||||
for (v, c) in plane.iter_mut().zip(&a) {
|
||||
*v = if *c > 1e-4 { (*v / c).clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels' straight colour channels and their coverage (0..1), as planes.
|
||||
fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
|
||||
let colours = px.iter().map(|p| straight(*p)).collect::<Vec<_>>();
|
||||
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
|
||||
}
|
||||
|
||||
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
|
||||
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
|
||||
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
|
||||
/// colours keep their hue. Slivers (the blends along softened edges) take the nearest large
|
||||
/// piece's colour, so the pieces meet with crisp edges. Coverage is kept.
|
||||
pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, levels: f64, simplicity: f64, fidelity: f64) {
|
||||
if !([levels, simplicity, fidelity].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let levels = levels.round().clamp(2.0, 8.0) as usize;
|
||||
// Simpler edges come from softer colours; more fidelity softens them less.
|
||||
let sigma = 0.6 * simplicity.clamp(0.0, 10.0) * (4.0 - fidelity.clamp(1.0, 3.0)) / 3.0 / space.px;
|
||||
let (mut colour, alpha) = planes(px);
|
||||
soften(&mut colour, &alpha, w, h, sigma);
|
||||
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
|
||||
let piece = |c: [f32; 3]| c.iter().fold(0, |n, v| n * levels + ((v * levels as f32) as usize).min(levels - 1));
|
||||
// Each piece's colour: the coverage-weighted mean of the softened colours in it.
|
||||
let mut sums = vec![[0.0f64; 4]; levels * levels * levels];
|
||||
for (k, a) in alpha.iter().enumerate() {
|
||||
let c = at(k);
|
||||
let Some(sum) = sums.get_mut(piece(c)) else { continue };
|
||||
for (s, v) in sum.iter_mut().zip(c) {
|
||||
*s += f64::from(v * a);
|
||||
}
|
||||
sum[3] += f64::from(*a);
|
||||
}
|
||||
let mean = |sum: &[f64; 4]| [0, 1, 2].map(|i| (sum[i] / sum[3]) as f32);
|
||||
let total: f64 = sums.iter().map(|s| s[3]).sum();
|
||||
let heaviest = sums.iter().map(|s| s[3]).fold(0.0, f64::max);
|
||||
// Pieces of at least 1% of the object (and always the largest one) are kept.
|
||||
let large: Vec<[f32; 3]> = sums.iter().filter(|s| s[3] > 0.0 && (s[3] >= 0.01 * total || s[3] >= heaviest)).map(mean).collect();
|
||||
let paint: Vec<Option<[f32; 3]>> = sums
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let c = (s[3] > 0.0).then(|| mean(s))?;
|
||||
let far = |o: &[f32; 3]| o.iter().zip(c).map(|(a, b)| (a - b) * (a - b)).sum::<f32>();
|
||||
large.iter().min_by(|a, b| far(a).total_cmp(&far(b))).copied()
|
||||
})
|
||||
.collect();
|
||||
for (k, p) in px.iter_mut().enumerate() {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(Some(c)) = paint.get(piece(at(k))) {
|
||||
*p = premultiply(*c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poster Edges: the object's colours posterized to `posterization` + 2 (2..8) levels per
|
||||
/// channel, with black lines drawn on the edges of any of its colour channels and of its outline. `thickness` (0..10) widens the lines and
|
||||
/// `intensity` (0..10) darkens them and lets fainter edges take a line. Coverage is kept.
|
||||
pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, intensity: f64, posterization: f64) {
|
||||
if !([thickness, intensity, posterization].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let steps = (posterization.round().clamp(0.0, 6.0) + 1.0) as f32;
|
||||
// The edges of each premultiplied channel (so the object's outline too), a step of 1 reading 1.
|
||||
let mut edges = vec![0.0f32; px.len()];
|
||||
for k in 0..4 {
|
||||
let plane: Vec<f32> = px.iter().map(|p| f32::from(p[k]) / 255.0).collect();
|
||||
for (e, s) in edges.iter_mut().zip(sobel(&plane, w, h)) {
|
||||
*e = e.max(s / 4.0);
|
||||
}
|
||||
}
|
||||
// Wider lines spread the edges; scale them back up so a spread line stays as dark.
|
||||
let sigma = (0.3 + 0.25 * thickness.clamp(0.0, 10.0)) / space.px;
|
||||
blur_plane(&mut edges, w, h, sigma);
|
||||
let gain = ((0.3 + 0.25 * intensity.clamp(0.0, 10.0)) * (1.0 + 1.5 * sigma.max(0.0))) as f32;
|
||||
for (p, e) in px.iter_mut().zip(edges) {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
let t = ((e * gain - 0.25) / 0.5).clamp(0.0, 1.0);
|
||||
let ink = t * t * (3.0 - 2.0 * t);
|
||||
let c = straight(*p).map(|v| (v * steps).round() / steps * (1.0 - ink));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
|
||||
pub(super) fn glow_spread(size: f64) -> f64 {
|
||||
0.25 * size.abs().min(24.0)
|
||||
}
|
||||
|
||||
/// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size`
|
||||
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
|
||||
/// strong as `brightness` (0..50). Coverage is kept.
|
||||
pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, brightness: f64, colour: [f32; 3]) {
|
||||
if !([size, brightness].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let strength = (brightness / 50.0).clamp(0.0, 1.0) as f32;
|
||||
let tones: Vec<f32> = px.iter().map(|p| luma(straight(*p))).collect();
|
||||
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
|
||||
let mut glow = [tones.iter().map(|l| if size < 0.0 { 1.0 - l } else { *l }).collect::<Vec<_>>()];
|
||||
soften(&mut glow, &alpha, w, h, glow_spread(size) / space.px);
|
||||
let [glow] = glow;
|
||||
for ((p, tone), g) in px.iter_mut().zip(tones).zip(glow) {
|
||||
if p[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
// The greys dim as the glow brightens, and the glow is screened over them.
|
||||
let base = tone * (1.0 - 0.6 * strength);
|
||||
let lit = (3.0 * g * strength).clamp(0.0, 1.0);
|
||||
let c = colour.map(|v| 1.0 - (1.0 - base) * (1.0 - v.clamp(0.0, 1.0) * lit));
|
||||
*p = premultiply(c, f32::from(p[3]) / 255.0);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,40 @@ fn close(a: &Px16, b: &Px16, t: i32) -> bool {
|
||||
a.iter().zip(b).all(|(a, b)| (*a as i32 - *b as i32).abs() <= t)
|
||||
}
|
||||
|
||||
/// The offsets Smart Blur looks at around a pixel out to about `r` pixels (up to round(`r`)),
|
||||
/// with the radius of the box blur of the copy they are read from (0: the pixels themselves):
|
||||
/// every pixel of the square of side 2 · round(`r`) + 1, or `samples` × `samples` points spread
|
||||
/// over the square once it is wider than `samples` pixels, read from a copy box-blurred over the
|
||||
/// gaps between them. The callers keep the offsets inside their disc.
|
||||
fn smart_grid(r: f64, samples: u32) -> (Vec<Vec2>, usize) {
|
||||
let n = samples.clamp(3, 15) as f64;
|
||||
let k = r.round();
|
||||
if 2.0 * k < n {
|
||||
let k = k as i32;
|
||||
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
|
||||
} else {
|
||||
let gap = 2.0 * r / (n - 1.0);
|
||||
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
|
||||
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far from a pixel Smart Blur's edge modes ([`smart_edges`]) read, in the units of `radius`:
|
||||
/// `radius`, and the box blur of the copy that a wide radius's offsets are read from.
|
||||
pub(super) fn edge_reach(radius: f64, samples: u32) -> f64 {
|
||||
radius + radius / (samples.clamp(3, 15) as f64 - 1.0)
|
||||
}
|
||||
|
||||
/// `src` (rows of `w` pixels) box-blurred with radius `prefilter`, the copy that the offsets of
|
||||
/// [`smart_grid`] are read from; `None` when they are read from `src` itself.
|
||||
fn smart_near(src: &[Px16], w: usize, prefilter: usize) -> Option<Vec<Px16>> {
|
||||
(prefilter > 0).then(|| {
|
||||
let mut b = src.to_vec();
|
||||
box_blur(&mut b, w, prefilter);
|
||||
b
|
||||
})
|
||||
}
|
||||
|
||||
/// Smart Blur (Normal mode): each pixel averaged with itself and the neighbours within `radius`
|
||||
/// pixels whose premultiplied channels all lie within `threshold` levels of its own. Wide radii
|
||||
/// look at `samples` × `samples` neighbours spread over the disc, read from a copy box-blurred
|
||||
@@ -113,28 +147,12 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
return;
|
||||
}
|
||||
let r = radius.min(MAX_SMART);
|
||||
let n = samples.clamp(3, 15) as f64;
|
||||
let k = r.round();
|
||||
let (offsets, prefilter): (Vec<Vec2>, usize) = if 2.0 * k < n {
|
||||
let k = k as i32;
|
||||
((-k..=k).flat_map(|dy| (-k..=k).map(move |dx| Vec2::new(dx as f64, dy as f64))).collect(), 0)
|
||||
} else {
|
||||
let gap = 2.0 * r / (n - 1.0);
|
||||
let grid = (0..n as i32).map(|i| -r + gap * i as f64);
|
||||
(grid.clone().flat_map(|dy| grid.clone().map(move |dx| Vec2::new(dx.round(), dy.round()))).collect(), (gap / 2.0).floor() as usize)
|
||||
};
|
||||
let (offsets, prefilter) = smart_grid(r, samples);
|
||||
let offsets: Vec<(isize, isize)> =
|
||||
offsets.into_iter().filter(|o| o.hypot() <= r + 0.5 && *o != Vec2::ZERO).map(|o| (o.x as isize, o.y as isize)).collect();
|
||||
let src = to16(px);
|
||||
let blurred;
|
||||
let near: &[Px16] = if prefilter > 0 {
|
||||
let mut b = src.clone();
|
||||
box_blur(&mut b, w, prefilter);
|
||||
blurred = b;
|
||||
&blurred
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
let blurred = smart_near(&src, w, prefilter);
|
||||
let near = blurred.as_deref().unwrap_or(&src);
|
||||
let t = levels(threshold);
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(px.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, o)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
@@ -162,33 +180,41 @@ pub(super) fn smart(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, thresho
|
||||
|
||||
/// Smart Blur's Edge Only (`overlay` false) and Overlay Edge (`overlay` true) modes: the pixels on
|
||||
/// an edge drawn white, over black or over the Normal mode's result ([`smart`]). A pixel is on an
|
||||
/// edge when one of its four nearest neighbors (transparent beyond the raster) has a premultiplied
|
||||
/// channel more than `threshold` levels from its own and its channels add up to at least the
|
||||
/// neighbor's, so a step between two colors gives a line one pixel wide on the side whose channels
|
||||
/// add up to more, and two pixels wide when the sums are equal. The white and the black keep the
|
||||
/// pixel's coverage.
|
||||
/// edge when one of the pixels the search reads within `radius` pixels of it (transparent beyond
|
||||
/// the raster) has a premultiplied channel more than `threshold` levels from its own and its
|
||||
/// channels add up to at least that pixel's. The search reads the Normal mode's offsets ([`smart_grid`]) for the whole
|
||||
/// pixels of `radius`, kept within `radius`, from the pixels and, for a wide radius, from the
|
||||
/// box-blurred copy, and always the four nearest pixels. A straight step between two colors gives
|
||||
/// a line on the side whose channels add up to more, about `radius` wide (the whole pixels of
|
||||
/// `radius` while every pixel within it is read, and at least one pixel), and lines on both sides
|
||||
/// when the sums are equal. The white and the black keep the pixel's coverage.
|
||||
pub(super) fn smart_edges(px: &mut [[u8; 4]], w: usize, h: usize, radius: f64, threshold: f64, samples: u32, overlay: bool) {
|
||||
let src = to16(px);
|
||||
let t = levels(threshold);
|
||||
let sum = |p: &Px16| p.iter().map(|v| u32::from(*v)).sum::<u32>();
|
||||
let at = |x: usize, y: usize, dx: isize, dy: isize| -> Px16 {
|
||||
let (Some(x), Some(y)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { return [0; 4] };
|
||||
if x >= w || y >= h {
|
||||
return [0; 4];
|
||||
let r = if radius.is_nan() { 0.0 } else { radius.min(MAX_SMART) };
|
||||
// Spread over whole pixels, so that the outermost offsets lie within `r` and are kept.
|
||||
let (offsets, prefilter) = smart_grid(r.floor(), samples);
|
||||
let wide: Vec<(isize, isize)> = offsets.into_iter().filter(|o| o.hypot() > 1.0 && o.hypot() <= r).map(|o| (o.x as isize, o.y as isize)).collect();
|
||||
let nearest = [(1, 0), (-1, 0), (0, 1), (0, -1)];
|
||||
let blurred = smart_near(&src, w, prefilter);
|
||||
let mut edge = vec![false; src.len()];
|
||||
for (y, (row, out)) in src.chunks_exact(w).zip(edge.chunks_exact_mut(w)).enumerate().take(h) {
|
||||
for (x, (c, e)) in row.iter().zip(out.iter_mut()).enumerate() {
|
||||
let own = sum(c);
|
||||
let differs = |n: &Px16| !close(n, c, t) && own >= sum(n);
|
||||
// The pixel at the offset and, with `copy`, its blurred copy; transparent beyond the raster.
|
||||
let found = |(dx, dy): &(isize, isize), copy: bool| {
|
||||
let (sx, sy) = (x as isize + dx, y as isize + dy);
|
||||
if sx < 0 || sy < 0 || sx as usize >= w || sy as usize >= h {
|
||||
return differs(&[0; 4]);
|
||||
}
|
||||
let i = sy as usize * w + sx as usize;
|
||||
src.get(i).is_some_and(differs) || (copy && blurred.as_deref().and_then(|b| b.get(i)).is_some_and(differs))
|
||||
};
|
||||
*e = nearest.iter().any(|o| found(o, false)) || wide.iter().any(|o| found(o, true));
|
||||
}
|
||||
src.get(y * w + x).copied().unwrap_or([0; 4])
|
||||
};
|
||||
let edge: Vec<bool> = src
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let (x, y) = (i % w.max(1), i / w.max(1));
|
||||
[(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
|
||||
let n = at(x, y, *dx, *dy);
|
||||
!close(&n, c, t) && sum(c) >= sum(&n)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if overlay {
|
||||
smart(px, w, h, radius, threshold, samples);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, Blur › Radial Blur and Smart Blur, Brush Strokes ›
|
||||
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
|
||||
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
|
||||
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
|
||||
@@ -39,8 +39,11 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
|
||||
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
|
||||
|
||||
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
|
||||
pub const PIXEL_EFFECTS: [&str; 29] = [
|
||||
pub const PIXEL_EFFECTS: [&str; 32] = [
|
||||
"artistic.cutout",
|
||||
"artistic.filmGrain",
|
||||
"artistic.neonGlow",
|
||||
"artistic.posterEdges",
|
||||
"blur.radial",
|
||||
"blur.smart",
|
||||
"brushStrokes.accentedEdges",
|
||||
@@ -75,18 +78,27 @@ pub const PIXEL_EFFECTS: [&str; 29] = [
|
||||
/// are in document units.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PixelFx {
|
||||
/// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their
|
||||
/// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say.
|
||||
Cutout { levels: f64, simplicity: f64, fidelity: f64 },
|
||||
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
|
||||
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
|
||||
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
|
||||
/// Artistic › Neon Glow: dimmed greys lit by `colour` (straight RGB, 0..1) glowing from the
|
||||
/// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as
|
||||
/// strong as `brightness` (0..50).
|
||||
NeonGlow { size: f64, brightness: f64, colour: [f32; 3] },
|
||||
/// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black
|
||||
/// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10).
|
||||
PosterEdges { thickness: f64, intensity: f64, posterization: f64 },
|
||||
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
|
||||
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
|
||||
RadialBlur { amount: f64, zoom: bool, passes: u32 },
|
||||
/// Blur › Smart Blur (Normal mode): each pixel averaged with the neighbours within `radius`
|
||||
/// whose colour differs from its own by no more than `threshold` levels, over a grid of
|
||||
/// `samples` × `samples` neighbours at most.
|
||||
/// The Edge Only `mode` draws the edges where neighboring pixels differ by more than
|
||||
/// `threshold` levels in white on black, and Overlay Edge draws them in white over the Normal
|
||||
/// result.
|
||||
/// The Edge Only `mode` draws the edges that `threshold` and `radius` find (`blur::smart_edges`)
|
||||
/// in white on black, and Overlay Edge draws them in white over the Normal result.
|
||||
SmartBlur { radius: f64, threshold: f64, samples: u32, mode: SmartBlurMode },
|
||||
/// Brush Strokes › Accented Edges: the edges, about `width` (1..14) wide, accented towards
|
||||
/// white chalk or black ink as `brightness` (0..50) says; `smoothness` (1..15) softens them.
|
||||
@@ -210,6 +222,21 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
|
||||
};
|
||||
Some(match id {
|
||||
"artistic.cutout" => PixelFx::Cutout {
|
||||
levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0),
|
||||
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
|
||||
fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0),
|
||||
},
|
||||
"artistic.neonGlow" => PixelFx::NeonGlow {
|
||||
size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0),
|
||||
brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0),
|
||||
colour: crate::raster::color_param(p, vectorcraft_doc::color::Color::rgb(0.3, 0.65, 1.0)).to_rgb(),
|
||||
},
|
||||
"artistic.posterEdges" => PixelFx::PosterEdges {
|
||||
thickness: num(p, "edgeThickness", 2.0).clamp(0.0, 10.0),
|
||||
intensity: num(p, "edgeIntensity", 1.0).clamp(0.0, 10.0),
|
||||
posterization: num(p, "posterization", 2.0).round().clamp(0.0, 6.0),
|
||||
},
|
||||
"artistic.filmGrain" => PixelFx::FilmGrain {
|
||||
grain: num(p, "grain", 4.0).clamp(0.0, 20.0),
|
||||
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
|
||||
@@ -368,6 +395,9 @@ impl PixelFx {
|
||||
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
|
||||
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
|
||||
PixelFx::FilmGrain { .. }
|
||||
| PixelFx::Cutout { .. }
|
||||
| PixelFx::NeonGlow { .. }
|
||||
| PixelFx::PosterEdges { .. }
|
||||
| PixelFx::ColorHalftone { .. }
|
||||
| PixelFx::Mezzotint { .. }
|
||||
| PixelFx::Craquelure { .. }
|
||||
@@ -388,7 +418,8 @@ impl PixelFx {
|
||||
pub fn reach(&self) -> Option<f64> {
|
||||
match *self {
|
||||
PixelFx::RadialBlur { .. } => None,
|
||||
PixelFx::SmartBlur { radius, .. } => Some(radius),
|
||||
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
|
||||
PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)),
|
||||
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
|
||||
// The strokes, and the softened tones that choose them.
|
||||
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
|
||||
@@ -409,6 +440,12 @@ impl PixelFx {
|
||||
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
|
||||
PixelFx::Crystallize { cell } => Some(1.5 * cell),
|
||||
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
|
||||
// Cutout's palette comes from the whole object.
|
||||
PixelFx::Cutout { .. } => None,
|
||||
// The softened tones around the pixel.
|
||||
PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)),
|
||||
// The edges' lines, spread round them.
|
||||
PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0),
|
||||
// The dots' points, and the softened colour around them.
|
||||
PixelFx::Pointillize { cell } => Some(2.5 * cell),
|
||||
// The softened tones around the pixel.
|
||||
@@ -429,7 +466,12 @@ impl PixelFx {
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
|
||||
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
|
||||
PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour),
|
||||
PixelFx::PosterEdges { thickness, intensity, posterization } => {
|
||||
artistic::poster_edges(px, w, h, space, thickness, intensity, posterization)
|
||||
}
|
||||
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
|
||||
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
|
||||
blur::smart(px, w, h, to_px(radius), threshold, samples)
|
||||
|
||||
@@ -500,6 +500,9 @@ fn outsets_follow_the_object() {
|
||||
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
|
||||
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(), Some(4.0));
|
||||
// The edge modes also read the copy blurred over a wide radius's samples.
|
||||
assert_eq!(fx("blur.smart", json!({"radius": 4, "quality": "low", "blurMode": "edgeOnly"})).reach(), Some(5.0));
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
|
||||
assert_eq!(spin.reach(), None);
|
||||
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
|
||||
@@ -656,8 +659,8 @@ fn smart_blur_normal_mode_keeps_its_pixels() {
|
||||
|
||||
/// Smart Blur's test image: grays 56 and 64 in a checkerboard for x < 3 and 66 and 74 for x in 3..6
|
||||
/// (steps of 2 or 18 levels at x = 3, under the threshold), gray 200 from x = 6 on (steps of 126 or
|
||||
/// 134 levels). Its edges at threshold 25 are the pixels on the raster's border (transparency is
|
||||
/// beyond it) and column 6, the step's lighter side.
|
||||
/// 134 levels). Its edges at threshold 25 and radius 1 are the pixels on the raster's border
|
||||
/// (transparency is beyond it) and column 6, the step's lighter side.
|
||||
fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool) {
|
||||
let (w, h) = (12, 8);
|
||||
let src = image(w, h, |x, y| match x {
|
||||
@@ -672,16 +675,16 @@ fn smart_blur_steps() -> (usize, usize, Vec<u8>, impl Fn(usize, usize) -> bool)
|
||||
fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut d, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1})).apply(&mut d, w, h, &space(w, h));
|
||||
let want = image(w, h, |x, y| if edge(x, y) { [255; 4] } else { [0, 0, 0, 255] });
|
||||
assert_eq!(d, want);
|
||||
// The radius changes only the blur.
|
||||
let mut wide = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut wide, w, h, &space(w, h));
|
||||
assert_eq!(wide, want);
|
||||
// Under one pixel the search still takes the four nearest pixels.
|
||||
let mut narrow = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 0.1})).apply(&mut narrow, w, h, &space(w, h));
|
||||
assert_eq!(narrow, want);
|
||||
// A threshold under the checkerboard's 8 levels makes its lighter squares edges too.
|
||||
let mut low = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "threshold": 5})).apply(&mut low, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1, "threshold": 5})).apply(&mut low, w, h, &space(w, h));
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..6 {
|
||||
let lighter = matches!(at(&src, w, x, y)[0], 64 | 74);
|
||||
@@ -692,7 +695,7 @@ fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
let (w, h) = (8, 8);
|
||||
let shape = image(w, h, |x, y| if (2..6).contains(&x) && (2..6).contains(&y) { [64, 0, 0, 128] } else { [0; 4] });
|
||||
let mut s = shape.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly"})).apply(&mut s, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 1})).apply(&mut s, w, h, &space(w, h));
|
||||
let ring = |x: usize, y: usize| (2..6).contains(&x) && (2..6).contains(&y) && !((3..5).contains(&x) && (3..5).contains(&y));
|
||||
let inside = |x: usize, y: usize| (3..5).contains(&x) && (3..5).contains(&y);
|
||||
let want = image(w, h, |x, y| match (ring(x, y), inside(x, y)) {
|
||||
@@ -707,10 +710,10 @@ fn smart_blur_edge_only_draws_the_edges_white_on_black() {
|
||||
fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
|
||||
let (w, h, src, edge) = smart_blur_steps();
|
||||
let mut normal = src.clone();
|
||||
fx("blur.smart", json!({})).apply(&mut normal, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"radius": 1})).apply(&mut normal, w, h, &space(w, h));
|
||||
assert_ne!(normal, src, "the checkerboard is blurred");
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge"})).apply(&mut d, w, h, &space(w, h));
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge", "radius": 1})).apply(&mut d, w, h, &space(w, h));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let want = if edge(x, y) { [255; 4] } else { at(&normal, w, x, y) };
|
||||
@@ -726,6 +729,87 @@ fn smart_blur_overlay_edge_draws_the_edges_white_over_the_blur() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The edges #2154 drew at any radius: a pixel is on an edge when one of its four nearest pixels
|
||||
/// (transparent beyond the raster) differs from it by more than `t` levels in a channel and its
|
||||
/// channels add up to at least that pixel's.
|
||||
fn four_neighbor_edges(src: &[u8], w: usize, h: usize, t: u8) -> Vec<bool> {
|
||||
let px = |x: isize, y: isize| if x < 0 || y < 0 || x as usize >= w || y as usize >= h { [0; 4] } else { at(src, w, x as usize, y as usize) };
|
||||
let sum = |p: [u8; 4]| p.iter().map(|v| u32::from(*v)).sum::<u32>();
|
||||
let mut edges = Vec::new();
|
||||
for y in 0..h as isize {
|
||||
for x in 0..w as isize {
|
||||
let c = px(x, y);
|
||||
edges.push([(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| {
|
||||
let n = px(x + dx, y + dy);
|
||||
c.iter().zip(&n).any(|(a, b)| a.abs_diff(*b) > t) && sum(c) >= sum(n)
|
||||
}));
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_edges_at_radius_one_are_the_four_neighbor_edges() {
|
||||
let (w, h) = (48, 40);
|
||||
let src = image(w, h, |x, y| {
|
||||
let a = if x > 4 && y < 36 { 255 } else { 128 };
|
||||
[((x * 5 + y) as u8).min(a), ((y * 6) as u8).min(a), (((x * y) % 97) as u8).min(a), a]
|
||||
});
|
||||
for t in [5, 10, 25, 60] {
|
||||
let edges = four_neighbor_edges(&src, w, h, t);
|
||||
assert!(edges.contains(&true) && edges.contains(&false), "threshold {t}");
|
||||
let want: Vec<u8> = src.as_chunks::<4>().0.iter().zip(&edges).flat_map(|(p, e)| if *e { [p[3]; 4] } else { [0, 0, 0, p[3]] }).collect();
|
||||
for radius in [0.1, 0.5, 1.0] {
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": radius, "threshold": t})).apply(&mut d, w, h, &space(w, h));
|
||||
assert_eq!(d, want, "threshold {t}, radius {radius}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_blur_radius_widens_the_edge_search() {
|
||||
// Gray 60 left of x = 15 and gray 200 from it on: a step of 140 levels.
|
||||
let (w, h) = (30, 16);
|
||||
let src = image(w, h, |x, _| grey(if x < 15 { 60 } else { 200 }));
|
||||
for radius in [1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5] {
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": radius})).apply(&mut d, w, h, &space(w, h));
|
||||
// The pixels within `radius` of a darker one: the step's first `r` columns on its lighter
|
||||
// side, and a band `r` wide along the raster's border (transparency is beyond it). Up to
|
||||
// radius 4.5 the search reads no blurred copy: at 4 and 4.5 the medium quality's 7 samples
|
||||
// across are 4/3 pixels apart, under the 2 pixels a box blur of radius 1 needs.
|
||||
let r = radius as usize;
|
||||
let want =
|
||||
image(w, h, |x, y| if x < r || y < r || x >= w - r || y >= h - r || (15..15 + r).contains(&x) { [255; 4] } else { [0, 0, 0, 255] });
|
||||
assert_eq!(d, want, "radius {radius}");
|
||||
}
|
||||
// Overlay Edge draws the same edges over the blur.
|
||||
let mut normal = src.clone();
|
||||
fx("blur.smart", json!({"radius": 3})).apply(&mut normal, w, h, &space(w, h));
|
||||
let mut over = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "overlayEdge", "radius": 3})).apply(&mut over, w, h, &space(w, h));
|
||||
assert_eq!(at(&over, w, 17, 8), [255; 4]);
|
||||
assert_eq!(at(&over, w, 18, 8), at(&normal, w, 18, 8));
|
||||
// A wide radius reads a grid of samples, also through a blurred copy, and finds the step from
|
||||
// the radius away, on its lighter side only.
|
||||
let (w, h) = (260, 100);
|
||||
let src = image(w, h, |x, _| grey(if x < 100 { 60 } else { 200 }));
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut d, w, h, &space(w, h));
|
||||
for (x, edge) in [(100, true), (130, true), (139, true), (170, false), (99, false), (70, false)] {
|
||||
assert_eq!(at(&d, w, x, 50)[0] == 255, edge, "({x}, 50)");
|
||||
}
|
||||
// A dark band narrower than the gaps between the samples is found through the blurred copy:
|
||||
// at x = 120 the samples fall on x = 107, 93 and 80, all light.
|
||||
let src = image(w, h, |x, _| grey(if (100..104).contains(&x) { 60 } else { 200 }));
|
||||
let mut d = src.clone();
|
||||
fx("blur.smart", json!({"blurMode": "edgeOnly", "radius": 40})).apply(&mut d, w, h, &space(w, h));
|
||||
for (x, edge) in [(104, true), (120, true), (102, false)] {
|
||||
assert_eq!(at(&d, w, x, 50)[0] == 255, edge, "band ({x}, 50)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsharp_mask_overshoots_at_edges() {
|
||||
let (w, h) = (40, 10);
|
||||
@@ -2009,3 +2093,199 @@ fn halftone_draws_in_black_and_white_only() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The distinct colours of the covered pixels.
|
||||
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
|
||||
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
|
||||
}
|
||||
|
||||
/// A left-to-right rainbow ramp, half covered at its bottom rows.
|
||||
fn ramp(w: usize, h: usize) -> Vec<u8> {
|
||||
image(w, h, |x, y| {
|
||||
let t = x * 255 / (w - 1);
|
||||
let a = if y + 4 >= h { 128 } else { 255 };
|
||||
let c = [t, 255 - t, (t * 3) % 256].map(|v| (v * a / 255) as u8);
|
||||
[c[0], c[1], c[2], a as u8]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
|
||||
PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 }
|
||||
);
|
||||
assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 });
|
||||
assert_eq!(
|
||||
fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})),
|
||||
PixelFx::PosterEdges { thickness: 10.0, intensity: 0.0, posterization: 6.0 }
|
||||
);
|
||||
let PixelFx::NeonGlow { size, brightness, colour } = fx("artistic.neonGlow", json!({})) else { panic!() };
|
||||
assert_eq!((size, brightness), (5.0, 15.0));
|
||||
assert!((colour[0] - 0.3).abs() < 0.01 && (colour[1] - 0.65).abs() < 0.01 && colour[2] == 1.0, "{colour:?}");
|
||||
assert_eq!(
|
||||
fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})),
|
||||
PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] }
|
||||
);
|
||||
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] {
|
||||
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
|
||||
let (w, h) = (16, 12);
|
||||
let src = ramp(w, h);
|
||||
let run = |f: &PixelFx, s: &PixelSpace| {
|
||||
let mut out = src.clone();
|
||||
f.apply(&mut out, w, h, s);
|
||||
out
|
||||
};
|
||||
let colour = [0.3, 0.65, 1.0];
|
||||
let filters = [
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
|
||||
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
|
||||
];
|
||||
for f in &filters {
|
||||
assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space");
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
for f in &filters {
|
||||
for px in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
if run(f, &PixelSpace { px, ..space(w, h) }) != src {
|
||||
changed.push(format!("{f:?} at px {px}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in [
|
||||
PixelFx::Cutout { levels: f64::NAN, simplicity: 4.0, fidelity: 2.0 },
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: f64::INFINITY, fidelity: 2.0 },
|
||||
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: f64::NEG_INFINITY },
|
||||
PixelFx::NeonGlow { size: f64::NAN, brightness: 15.0, colour },
|
||||
PixelFx::NeonGlow { size: 5.0, brightness: f64::INFINITY, colour },
|
||||
PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 },
|
||||
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY },
|
||||
] {
|
||||
if run(&f, &space(w, h)) != src {
|
||||
changed.push(format!("{f:?}"));
|
||||
}
|
||||
}
|
||||
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
|
||||
let (w, h) = (64, 24);
|
||||
let src = ramp(w, h);
|
||||
for levels in [2, 3, 5, 8] {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.cutout", json!({"levels": levels})).apply(&mut out, w, h, &space(w, h));
|
||||
let opaque: std::collections::BTreeSet<_> = colours(&out).into_iter().filter(|p| p[3] == 255).collect();
|
||||
assert!(opaque.len() <= levels * levels * levels && opaque.len() >= 2, "{levels} levels: {} colours", opaque.len());
|
||||
assert!(opaque.len() < 64, "flat pieces, not a ramp: {levels} levels, {} colours", opaque.len());
|
||||
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutout_simplicity_smooths_the_pieces_edges() {
|
||||
// A noisy grey: simpler edges join the speckles into fewer changes between neighbours.
|
||||
let (w, h) = (48, 48);
|
||||
let src = image(w, h, |x, y| grey(if (x * 7 + y * 13) % 5 < 2 { 60 } else { 190 }));
|
||||
let changes = |simplicity: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.cutout", json!({"levels": 2, "edgeSimplicity": simplicity, "edgeFidelity": 1})).apply(&mut out, w, h, &space(w, h));
|
||||
out.as_chunks::<4>().0.windows(2).filter(|p| p[0] != p[1]).count()
|
||||
};
|
||||
assert!(changes(10.0) * 4 < changes(0.0), "{} vs {}", changes(10.0), changes(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poster_edges_draw_black_lines_on_edges_and_posterize_the_rest() {
|
||||
let (w, h) = (40, 20);
|
||||
// Two flat halves: a line where they meet, posterized colour away from it.
|
||||
let src = image(w, h, |x, _| if x < w / 2 { [200, 90, 30, 255] } else { [30, 120, 210, 255] });
|
||||
let mut out = src.clone();
|
||||
fx("artistic.posterEdges", json!({"edgeThickness": 2, "edgeIntensity": 10, "posterization": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
let line = at(&out, w, w / 2, h / 2);
|
||||
assert!(line[0] < 40 && line[1] < 40 && line[2] < 40, "a dark line on the edge: {line:?}");
|
||||
// 2 levels per channel: every far pixel is 0 or 255 in each channel.
|
||||
for x in [3, w - 4] {
|
||||
let p = at(&out, w, x, h / 2);
|
||||
assert!(p[..3].iter().all(|v| *v == 0 || *v == 255), "{x}: {p:?}");
|
||||
}
|
||||
// Inside a flat colour, away from the object's outline, there are no lines: it only posterizes.
|
||||
let mut flat = image(w, h, |_, _| [200, 90, 30, 255]);
|
||||
fx("artistic.posterEdges", json!({"edgeIntensity": 10, "posterization": 6})).apply(&mut flat, w, h, &space(w, h));
|
||||
let inside: std::collections::BTreeSet<_> = (6..h - 6).flat_map(|y| (6..w - 6).map(move |x| (x, y))).map(|(x, y)| at(&flat, w, x, y)).collect();
|
||||
assert_eq!(inside.len(), 1, "{inside:?}");
|
||||
assert!(at(&flat, w, 0, h / 2)[0] < 60, "the outline takes a line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poster_edges_thickness_widens_the_lines() {
|
||||
let (w, h) = (60, 10);
|
||||
let src = image(w, h, |x, _| grey(if x < w / 2 { 30 } else { 230 }));
|
||||
let dark = |thickness: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.posterEdges", json!({"edgeThickness": thickness, "edgeIntensity": 10, "posterization": 6})).apply(&mut out, w, h, &space(w, h));
|
||||
(0..w).filter(|x| at(&out, w, *x, h / 2)[0] < 20 && (*x >= w / 2 || at(&src, w, *x, h / 2)[0] > 20)).count()
|
||||
};
|
||||
assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() {
|
||||
let (w, h) = (40, 20);
|
||||
let src = image(w, h, |x, _| grey(if x < w / 2 { 20 } else { 235 }));
|
||||
let run = |size: f64| {
|
||||
let mut out = src.clone();
|
||||
fx("artistic.neonGlow", json!({"glowSize": size, "glowBrightness": 40, "color": "#ff0000"})).apply(&mut out, w, h, &space(w, h));
|
||||
(at(&out, w, 3, h / 2), at(&out, w, w - 4, h / 2))
|
||||
};
|
||||
// Red glow: the red channel rises above green and blue where it glows.
|
||||
let red = |p: [u8; 4]| i32::from(p[0]) - i32::from(p[1]);
|
||||
let (dark, light) = run(8.0);
|
||||
assert!(red(light) > 60 && red(light) > red(dark) + 40, "light areas glow: {dark:?} {light:?}");
|
||||
let (dark, light) = run(-8.0);
|
||||
assert!(red(dark) > 60 && red(dark) > red(light) + 40, "dark areas glow: {dark:?} {light:?}");
|
||||
// No brightness: dimmed greys only.
|
||||
let mut out = src.clone();
|
||||
fx("artistic.neonGlow", json!({"glowBrightness": 0})).apply(&mut out, w, h, &space(w, h));
|
||||
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_filters_look_the_same_at_any_resolution() {
|
||||
// The same art at 1 and at 2 pixels per point: the filter at the coarse resolution matches
|
||||
// the fine result sampled at the same points.
|
||||
let (w, h) = (48, 32);
|
||||
let art = |x: f64, y: f64| grey(if (x - 24.0).hypot(y - 16.0) < 10.0 { 230 } else { 40 });
|
||||
let coarse = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let fine = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
|
||||
let mut half = space(2 * w, 2 * h);
|
||||
half.to_doc = Affine::scale(0.5);
|
||||
half.px = 0.5;
|
||||
half.center = Point::new(w as f64 / 2.0, h as f64 / 2.0);
|
||||
for (id, p) in [
|
||||
("artistic.cutout", json!({"edgeSimplicity": 6})),
|
||||
("artistic.neonGlow", json!({"glowSize": 12, "glowBrightness": 30})),
|
||||
("artistic.posterEdges", json!({"edgeThickness": 6, "edgeIntensity": 6})),
|
||||
] {
|
||||
let (mut a, mut b) = (coarse.clone(), fine.clone());
|
||||
fx(id, p.clone()).apply(&mut a, w, h, &space(w, h));
|
||||
fx(id, p).apply(&mut b, 2 * w, 2 * h, &half);
|
||||
let diff: f64 = (0..h)
|
||||
.flat_map(|y| (0..w).map(move |x| (x, y)))
|
||||
.map(|(x, y)| {
|
||||
let (p, q) = (at(&a, w, x, y), at(&b, 2 * w, 2 * x, 2 * y));
|
||||
(0..3).map(|k| f64::from(p[k].abs_diff(q[k]))).sum::<f64>() / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ (w * h) as f64;
|
||||
assert!(diff < 12.0, "{id}: mean difference {diff}");
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
//! Raster (painted) effects: descriptions for the renderer.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_doc::color::{BlendMode, Color};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
|
||||
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::pixel::{self, PixelFx};
|
||||
use crate::pixel::{self, PixelFx, PixelSpace};
|
||||
use crate::util::*;
|
||||
|
||||
/// Effect › Rasterize's id.
|
||||
pub const RASTERIZE: &str = "rasterize";
|
||||
|
||||
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
|
||||
/// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -48,6 +52,8 @@ pub enum RasterFx {
|
||||
},
|
||||
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
|
||||
Pixel(PixelFx),
|
||||
/// Effect › Rasterize: the object drawn as an image of square pixels.
|
||||
Rasterize(Rasterize),
|
||||
}
|
||||
|
||||
impl RasterFx {
|
||||
@@ -71,6 +77,278 @@ impl RasterFx {
|
||||
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
|
||||
RasterFx::GaussianBlur { radius } => 1.5 * radius,
|
||||
RasterFx::Pixel(p) => p.outset(bounds),
|
||||
RasterFx::Rasterize(r) => r.outset(),
|
||||
}
|
||||
}
|
||||
/// This effect with a Rasterize that takes the document's resolution at `ppi` ([`Rasterize::at_document_ppi`]).
|
||||
pub fn at_document_ppi(&self, ppi: f64) -> RasterFx {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => RasterFx::Rasterize(r.at_document_ppi(ppi)),
|
||||
fx => fx.clone(),
|
||||
}
|
||||
}
|
||||
/// What this effect paints over when what it paints around covers `bounds`: Rasterize's image
|
||||
/// ([`Rasterize::image`], `line` the document's raster grid), `bounds` grown by
|
||||
/// [`Self::outset`] for the others.
|
||||
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
|
||||
match self {
|
||||
RasterFx::Rasterize(r) => r.image(bounds, line),
|
||||
fx => {
|
||||
let o = fx.outset(bounds);
|
||||
bounds.inflate(o, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect › Rasterize's options, read from its parameters. The image covers the object's bounds
|
||||
/// (as far as the effects before it spread the object) and `add_around` points around them, in
|
||||
/// pixels `72 / ppi` points wide whose first one starts at that area's top-left corner, as Object
|
||||
/// › Rasterize makes its image.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Rasterize {
|
||||
/// Pixels per inch (1 to 2400); `None` takes Document Raster Effects Settings › Resolution.
|
||||
pub ppi: Option<f64>,
|
||||
/// The image's color model.
|
||||
pub color_model: RasterColorModel,
|
||||
/// A white background: the image is opaque. Otherwise it keeps its transparency.
|
||||
pub white: bool,
|
||||
/// Smooth edges (Art Optimized or Type Optimized). Off, each pixel takes the art at its center.
|
||||
pub anti_alias: bool,
|
||||
/// Type Optimized: smooth edges with type fitted to the pixels.
|
||||
pub hinted: bool,
|
||||
/// The image is clipped to the art's outline.
|
||||
pub clipping_mask: bool,
|
||||
/// Room around the art, in points (0 to [`MAX_ADD_AROUND`]).
|
||||
pub add_around: f64,
|
||||
}
|
||||
|
||||
impl Rasterize {
|
||||
/// The options in the effect's parameters `p` (defaults merged in).
|
||||
fn parse(p: &Value) -> Self {
|
||||
let ppi = (!flag(p, "useDocumentResolution", true)).then(|| num(p, "resolution", 72.0).clamp(1.0, 2400.0));
|
||||
let color_model = match text(p, "colorModel", "document").to_ascii_lowercase().as_str() {
|
||||
"grayscale" | "gray" => RasterColorModel::Grayscale,
|
||||
"bitmap" => RasterColorModel::Bitmap,
|
||||
_ => RasterColorModel::Document,
|
||||
};
|
||||
Self {
|
||||
ppi,
|
||||
color_model,
|
||||
white: text(p, "background", "transparent").eq_ignore_ascii_case("white"),
|
||||
anti_alias: !text(p, "antiAlias", "art").eq_ignore_ascii_case("none"),
|
||||
hinted: text(p, "antiAlias", "art").eq_ignore_ascii_case("type"),
|
||||
clipping_mask: flag(p, "clippingMask", false),
|
||||
add_around: num(p, "addAround", 0.0).clamp(0.0, MAX_ADD_AROUND),
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of a pixel of the image in document units: 72 / `ppi`, or `line` (72 / the
|
||||
/// document's raster effects resolution) when it takes the document's.
|
||||
pub fn cell(&self, line: f64) -> f64 {
|
||||
self.ppi.map_or(line, |ppi| 72.0 / ppi)
|
||||
}
|
||||
|
||||
/// These options at `ppi` when they take the document's resolution.
|
||||
pub fn at_document_ppi(self, ppi: f64) -> Self {
|
||||
Self { ppi: self.ppi.or(Some(ppi)), ..self }
|
||||
}
|
||||
|
||||
/// How far beyond the object's bounds the image reaches: the room around it and, at a known
|
||||
/// resolution, one pixel more for the last column and row, which are whole pixels.
|
||||
pub fn outset(&self) -> f64 {
|
||||
self.add_around + self.ppi.map_or(0.0, |ppi| 72.0 / ppi)
|
||||
}
|
||||
|
||||
/// How far (document units) from a pixel the content that decides it lies: across its pixel
|
||||
/// of the image (72 points when the resolution is the document's, not known here).
|
||||
pub fn reach(&self) -> f64 {
|
||||
self.cell(72.0) * std::f64::consts::SQRT_2
|
||||
}
|
||||
|
||||
/// The image of art over `bounds`: `bounds` and the room around them, in whole pixels
|
||||
/// ([`Self::cell`] of `line`) from that area's top-left corner.
|
||||
pub fn image(&self, bounds: Rect, line: f64) -> Rect {
|
||||
let cell = self.cell(line);
|
||||
let area = bounds.inflate(self.add_around, self.add_around);
|
||||
let (cols, rows) = ((area.width() / cell).ceil().max(1.0), (area.height() / cell).ceil().max(1.0));
|
||||
Rect::new(area.x0, area.y0, area.x0 + cols * cell, area.y0 + rows * cell)
|
||||
}
|
||||
|
||||
/// Give `pixels` (premultiplied RGBA8), the art drawn on the image's own grid, the background
|
||||
/// and then the color model, as Object › Rasterize does.
|
||||
pub fn finish(&self, pixels: &mut [u8]) {
|
||||
for p in pixels.as_chunks_mut::<4>().0 {
|
||||
if self.white {
|
||||
let under = 255 - p[3];
|
||||
*p = p.map(|c| c.saturating_add(under));
|
||||
}
|
||||
}
|
||||
RasterEffectsSettings { color_model: self.color_model, ..Default::default() }.finish_pixels(pixels);
|
||||
}
|
||||
|
||||
/// Draw `image` into `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
|
||||
/// `space` says: `image` is `cols` × `rows` premultiplied RGBA8 pixels covering `rect` in the
|
||||
/// document. In a view that isn't turned, each pixel of `data` takes the mean of the image over
|
||||
/// its area (transparent outside the image); otherwise the mean of the image at up to 8 × 8
|
||||
/// points across it. Does nothing when the sizes don't match.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compose(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, image: &[u8], cols: usize, rows: usize, rect: Rect) {
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
let (src, src_rest) = image.as_chunks::<4>();
|
||||
let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n);
|
||||
if !(fits(px.len(), w, h, rest.len()) && fits(src.len(), cols, rows, src_rest.len()) && rect.width() > 0.0 && rect.height() > 0.0) {
|
||||
return;
|
||||
}
|
||||
let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height());
|
||||
// Document → image pixels.
|
||||
let to_image = |p: Point| ((p.x - rect.x0) * sx, (p.y - rect.y0) * sy);
|
||||
let at = |i: f64, j: f64| {
|
||||
(i >= 0.0 && j >= 0.0 && i < cols as f64 && j < rows as f64).then(|| src.get(j as usize * cols + i as usize).copied()).flatten()
|
||||
};
|
||||
let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0);
|
||||
// Points across a raster pixel in a turned view: about one an image pixel.
|
||||
let n = (space.px * sx.max(sy)).ceil().clamp(1.0, 8.0) as usize;
|
||||
for (k, p) in px.iter_mut().enumerate() {
|
||||
let (x, y) = ((k % w) as f64, (k / w) as f64);
|
||||
let mut acc = [0.0f64; 4];
|
||||
let weight = if straight {
|
||||
let (a, b) = (to_image(space.to_doc * Point::new(x, y)), to_image(space.to_doc * Point::new(x + 1.0, y + 1.0)));
|
||||
let (u0, u1, v0, v1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1));
|
||||
let (i0, i1) = (u0.floor().max(0.0), u1.ceil().min(cols as f64));
|
||||
let (j0, j1) = (v0.floor().max(0.0), v1.ceil().min(rows as f64));
|
||||
let mut j = j0;
|
||||
while j < j1 {
|
||||
let mut i = i0;
|
||||
while i < i1 {
|
||||
let share = (u1.min(i + 1.0) - u0.max(i)).max(0.0) * (v1.min(j + 1.0) - v0.max(j)).max(0.0);
|
||||
if let Some(q) = at(i, j) {
|
||||
for (s, v) in acc.iter_mut().zip(q) {
|
||||
*s += share * f64::from(v);
|
||||
}
|
||||
}
|
||||
i += 1.0;
|
||||
}
|
||||
j += 1.0;
|
||||
}
|
||||
(u1 - u0) * (v1 - v0)
|
||||
} else {
|
||||
for t in 0..n * n {
|
||||
let (fx, fy) = (((t % n) as f64 + 0.5) / n as f64, ((t / n) as f64 + 0.5) / n as f64);
|
||||
let (i, j) = to_image(space.to_doc * Point::new(x + fx, y + fy));
|
||||
if let Some(q) = at(i.floor(), j.floor()) {
|
||||
for (s, v) in acc.iter_mut().zip(q) {
|
||||
*s += f64::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
(n * n) as f64
|
||||
};
|
||||
*p = if weight > 0.0 { acc.map(|s| (s / weight).round().clamp(0.0, 255.0) as u8) } else { [0; 4] };
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn `data`, `w` × `h` premultiplied RGBA8 pixels of the object lying in the document as
|
||||
/// `space` says, into its image over `bounds` (what the object covers after the effects before
|
||||
/// this one), when the image is too large to draw on its own grid. Each pixel of the image more
|
||||
/// than a raster pixel wide takes the mean of the raster pixels whose centers lie in it
|
||||
/// (anti-aliased) or the raster pixel at its center (not anti-aliased). Smaller pixels are left
|
||||
/// as drawn. Then the background and the color model apply as Object › Rasterize applies them.
|
||||
/// What lies outside the image becomes transparent, and in a view that isn't turned the raster
|
||||
/// pixels along its edges keep the share of them it covers. Does nothing when the sizes don't
|
||||
/// match. The renderer draws the content with hard edges when it goes into Rasterize with
|
||||
/// anti-aliasing off and image pixels at least a raster pixel wide.
|
||||
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, bounds: Rect) {
|
||||
let cell = self.cell(space.line);
|
||||
let (px, rest) = data.as_chunks_mut::<4>();
|
||||
if w == 0 || h == 0 || !rest.is_empty() || w.checked_mul(h) != Some(px.len()) || !(cell.is_finite() && cell > 0.0 && space.px > 0.0) {
|
||||
return;
|
||||
}
|
||||
let area = self.image(bounds, space.line);
|
||||
let (cols, rows) = ((area.width() / cell).round().max(1.0), (area.height() / cell).round().max(1.0));
|
||||
// The pixel of the image (column, row) that a raster pixel's center lies in.
|
||||
let pixel_at = |x: usize, y: usize| {
|
||||
let p = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let (c, r) = (((p.x - area.x0) / cell).floor(), ((p.y - area.y0) / cell).floor());
|
||||
(c >= 0.0 && r >= 0.0 && c < cols && r < rows).then_some((c, r))
|
||||
};
|
||||
let wide = cell / space.px;
|
||||
// The image's pixels the raster covers: columns c0..c1, rows r0..r1.
|
||||
let seen = space.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
let (c0, c1) = (((seen.x0 - area.x0) / cell).floor().clamp(0.0, cols), ((seen.x1 - area.x0) / cell).ceil().clamp(0.0, cols));
|
||||
let (r0, r1) = (((seen.y0 - area.y0) / cell).floor().clamp(0.0, rows), ((seen.y1 - area.y0) / cell).ceil().clamp(0.0, rows));
|
||||
let span = (c1 - c0) as usize;
|
||||
let slot = |(c, r): (f64, f64)| ((r - r0) as usize).saturating_mul(span).saturating_add((c - c0) as usize);
|
||||
// Anti-aliased: the sums of each pixel's raster pixels (premultiplied) and their count,
|
||||
// which saturate past 16 million raster pixels to an image pixel. They are kept for the
|
||||
// whole grid when it has no more pixels than the raster, else for the pixels the raster
|
||||
// reaches (a turned view of a narrow raster).
|
||||
let grid = span.saturating_mul((r1 - r0) as usize);
|
||||
let whole = grid <= px.len();
|
||||
let mut dense: Vec<[u32; 5]> = vec![];
|
||||
let mut sparse: std::collections::HashMap<usize, [u32; 5]> = std::collections::HashMap::new();
|
||||
if self.anti_alias && wide > 1.0 {
|
||||
if whole {
|
||||
dense = vec![[0; 5]; grid];
|
||||
}
|
||||
for (i, p) in px.iter().enumerate() {
|
||||
let Some(k) = pixel_at(i % w, i / w).map(slot) else { continue };
|
||||
let s = if whole { dense.get_mut(k) } else { Some(sparse.entry(k).or_default()) };
|
||||
if let Some(s) = s {
|
||||
for (acc, v) in s.iter_mut().zip(p) {
|
||||
*acc = acc.saturating_add(u32::from(*v));
|
||||
}
|
||||
s[4] = s[4].saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sum = |k: usize| if whole { dense.get(k) } else { sparse.get(&k) };
|
||||
// Not anti-aliased: the raster as drawn, to read each pixel's center from.
|
||||
let drawn = (!self.anti_alias && wide > 1.0).then(|| px.to_vec());
|
||||
let to_raster = space.to_doc.inverse();
|
||||
let model = RasterEffectsSettings { color_model: self.color_model, ..Default::default() };
|
||||
// In a view that isn't turned, the raster pixels along the image's edges take the share of
|
||||
// them it covers; otherwise those whose center lies in it are in it.
|
||||
let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0);
|
||||
let image = to_raster.transform_rect_bbox(area);
|
||||
let cover =
|
||||
|x: f64, y: f64| (image.x1.min(x + 1.0) - image.x0.max(x)).clamp(0.0, 1.0) * (image.y1.min(y + 1.0) - image.y0.max(y)).clamp(0.0, 1.0);
|
||||
for (i, p) in px.iter_mut().enumerate() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let (at, share) = if straight {
|
||||
// The image pixel nearest the raster pixel's center.
|
||||
let q = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let at = (((q.x - area.x0) / cell).floor().clamp(0.0, cols - 1.0), ((q.y - area.y0) / cell).floor().clamp(0.0, rows - 1.0));
|
||||
(Some(at), cover(x as f64, y as f64))
|
||||
} else {
|
||||
(pixel_at(x, y), 1.0)
|
||||
};
|
||||
let Some(at) = at.filter(|_| share > 0.0) else {
|
||||
*p = [0; 4];
|
||||
continue;
|
||||
};
|
||||
// An image pixel's color, over the share of the raster pixel the image covers. (A raster
|
||||
// pixel left as drawn holds art the image covers whole.)
|
||||
let made = if let Some(s) = sum(slot(at)).filter(|s| s[4] > 0) {
|
||||
Some([0, 1, 2, 3].map(|k| ((u64::from(s[k]) + u64::from(s[4] / 2)) / u64::from(s[4])).min(255) as u8))
|
||||
} else {
|
||||
drawn.as_ref().map(|drawn| {
|
||||
let center = to_raster * Point::new(area.x0 + (at.0 + 0.5) * cell, area.y0 + (at.1 + 0.5) * cell);
|
||||
let (x, y) = (center.x.floor(), center.y.floor());
|
||||
let inside = x >= 0.0 && y >= 0.0 && x < w as f64 && y < h as f64;
|
||||
if inside { drawn.get(y as usize * w + x as usize).copied().unwrap_or_default() } else { [0; 4] }
|
||||
})
|
||||
};
|
||||
if let Some(v) = made {
|
||||
*p = if share < 1.0 { v.map(|c| (f64::from(c) * share).round() as u8) } else { v };
|
||||
}
|
||||
// White goes under the art first, over the image's share of the pixel, then the color
|
||||
// model applies, as in Object › Rasterize.
|
||||
if self.white {
|
||||
let under = ((255.0 * share).round() as u8).saturating_sub(p[3]);
|
||||
*p = p.map(|c| c.saturating_add(under));
|
||||
}
|
||||
model.finish_pixels(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +411,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)),
|
||||
id => RasterFx::Pixel(pixel::parse(id, &p)?),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_color: crate::lighting::light_color(p),
|
||||
lights: crate::lighting::lights(p),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
@@ -46,6 +48,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
/// Validate before a command mutates the document. Loading external files is still bounded
|
||||
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
|
||||
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
|
||||
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
|
||||
@@ -87,7 +90,9 @@ fn surface(
|
||||
let b = path.bounds()?;
|
||||
// A loaded file or a profile edited past the limits keeps drawing its source path instead
|
||||
// of crashing the frame. Commands validate the same evaluator and report its errors.
|
||||
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
|
||||
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
|
||||
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
|
||||
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
|
||||
// Visibility is an expansion option, never a change to live geometry or export baking.
|
||||
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
|
||||
// failure also retains the complete surface rather than losing any of the user's art.
|
||||
@@ -102,19 +107,42 @@ fn surface(
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces
|
||||
.into_iter()
|
||||
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
|
||||
.map(|f| vectorcraft_three_d::VisibleFace {
|
||||
path: f.path(),
|
||||
brightness: f.brightness,
|
||||
depth: f.depth,
|
||||
gradient: f.gradient,
|
||||
illumination: f.illumination,
|
||||
light_gradient: f.light_gradient,
|
||||
rgb_gradient: f.rgb_gradient,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
if let Some(g) = face.rgb_gradient
|
||||
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
|
||||
{
|
||||
return Arc::new(crate::lighting::rgb_gradient_art(n.id, &face.path, &paint, g));
|
||||
}
|
||||
if let (true, Paint::Gradient(_), Some(g)) = (colored, &paint, face.light_gradient) {
|
||||
return Arc::new(crate::lighting::gradient_art(n.id, &face.path, &paint, g));
|
||||
}
|
||||
let mut fill = FillLayer::new(paint.clone());
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
|
||||
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
|
||||
fill.paint = if colored {
|
||||
face.light_gradient
|
||||
.map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c))
|
||||
} else {
|
||||
face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0))
|
||||
};
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else if colored && matches!(paint, Paint::Gradient(_)) {
|
||||
items = crate::lighting::fills(&paint, face.illumination, face.brightness, true);
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
||||
|
||||
#[cfg(test)]
|
||||
use vectorcraft_doc::color::Color;
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
|
||||
@@ -65,6 +68,8 @@ pub fn view_options(p: &Value) -> View {
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_color: crate::lighting::light_color(p),
|
||||
lights: crate::lighting::lights(p),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
@@ -124,6 +129,7 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, rule) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
@@ -139,32 +145,38 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
/// blend mode (so overlapping facets don't add up their transparency).
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
|
||||
if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) {
|
||||
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
return group;
|
||||
}
|
||||
// Preserve existing white-light paints, alpha composition and seam joins exactly.
|
||||
let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) };
|
||||
let paint = &opaque;
|
||||
let children = facets
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
|
||||
} else {
|
||||
// Gradients and patterns keep their paint, darkened by a black layer.
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
|
||||
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
|
||||
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
|
||||
// paint over its neighbours.
|
||||
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
||||
if let Some(g) = face.rgb_gradient
|
||||
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
|
||||
{
|
||||
return Arc::new(crate::lighting::facet_art(n.id, &face.path(), paint, g, seam));
|
||||
}
|
||||
let items = crate::lighting::fills(paint, face.illumination, face.brightness, colored);
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
|
||||
if colored {
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
}
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
@@ -175,6 +187,11 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
if let Some(mask) = mask {
|
||||
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
|
||||
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
|
||||
}
|
||||
group
|
||||
}
|
||||
|
||||
@@ -200,6 +217,7 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let colored = view_options(&p).has_colored_lights();
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds, so they stay together.
|
||||
let mut out = vec![];
|
||||
@@ -207,7 +225,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let (area, rule) = region(path, rule, item);
|
||||
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
@@ -216,7 +234,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -834,6 +834,50 @@ fn drop_shadow_darkness_replaces_its_color_when_chosen() {
|
||||
assert_eq!(darkness(json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_compose_leaves_the_raster_alone_when_the_sizes_do_not_fit() {
|
||||
let r = match raster_effects(&[fx(RASTERIZE, json!({}))]).as_slice() {
|
||||
[RasterFx::Rasterize(r)] => *r,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let space = PixelSpace {
|
||||
to_doc: Affine::IDENTITY,
|
||||
px: 1.0,
|
||||
center: Point::new(1.0, 1.0),
|
||||
channels: pixel::Channels::Rgb,
|
||||
line: 1.0,
|
||||
extent: std::f64::consts::SQRT_2,
|
||||
};
|
||||
let compose = |len: usize, w: usize, h: usize, image: &[u8], cols: usize, rows: usize, rect: Rect| {
|
||||
let mut data = vec![7; len];
|
||||
r.compose(&mut data, w, h, &space, image, cols, rows, rect);
|
||||
data
|
||||
};
|
||||
// A 2 × 2 red image over a 2 × 2 raster of one-point pixels: each pixel takes the image's color.
|
||||
let red = [255, 0, 0, 255].repeat(4);
|
||||
let mut long = red.clone();
|
||||
long.extend([255, 0]);
|
||||
let (img, long, area) = (red.as_slice(), long.as_slice(), Rect::new(0.0, 0.0, 2.0, 2.0));
|
||||
assert_eq!(compose(16, 2, 2, img, 2, 2, area), red);
|
||||
let cases = [
|
||||
("w × h is not the raster's pixel count", 16, 1, 2, img, 2, 2, area),
|
||||
("the raster's length is not a multiple of 4", 18, 2, 2, img, 2, 2, area),
|
||||
("cols × rows is not the image's pixel count", 16, 2, 2, img, 1, 2, area),
|
||||
("the image's length is not a multiple of 4", 16, 2, 2, long, 2, 2, area),
|
||||
("the image has no width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 0.0, 2.0)),
|
||||
("the image has no height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, 0.0)),
|
||||
("the image has a negative width", 16, 2, 2, img, 2, 2, Rect::new(2.0, 0.0, 0.0, 2.0)),
|
||||
("the image has a NaN width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, f64::NAN, 2.0)),
|
||||
("the image has a NaN height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, f64::NAN)),
|
||||
];
|
||||
let failures: Vec<_> = cases
|
||||
.iter()
|
||||
.filter(|&&(_, len, w, h, image, cols, rows, rect)| compose(len, w, h, image, cols, rows, rect) != vec![7; len])
|
||||
.map(|c| c.0)
|
||||
.collect();
|
||||
assert!(failures.is_empty(), "{failures:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mode_names() {
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
|
||||
@@ -23,6 +23,10 @@ fn scale_effect_scales_distances_only() {
|
||||
scale_effect(&mut e, 2.0);
|
||||
assert_eq!((&e.params["x"], &e.params["y"], &e.params["blur"]), (&json!(6.0), &json!(8.0), &json!(10.0)));
|
||||
assert_eq!(e.params["opacity"], json!(75.0));
|
||||
// Rasterize's Add Around Object is a distance; its resolution isn't.
|
||||
let mut r = new_effect(RASTERIZE, &json!({"addAround": 10, "resolution": 150})).unwrap();
|
||||
scale_effect(&mut r, 2.0);
|
||||
assert_eq!((&r.params["addAround"], &r.params["resolution"]), (&json!(20.0), &json!(150)));
|
||||
// Outline Stroke without a weight follows its stroke's: nothing to scale.
|
||||
let mut o = Effect { id: "path.outlineStroke".into(), params: json!(null), visible: true };
|
||||
scale_effect(&mut o, 2.0);
|
||||
|
||||
@@ -27,7 +27,10 @@ fn faces(art: &Node) -> Vec<&Node> {
|
||||
|
||||
fn fill_rgb(n: &Node) -> [f32; 3] {
|
||||
match &n.appearance.items[0] {
|
||||
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
|
||||
AppearanceItem::Fill(f) => match &f.paint {
|
||||
Paint::Gradient(g) => g.gradient.sample(0.5).0.to_rgb(),
|
||||
p => p.color().unwrap().to_rgb(),
|
||||
},
|
||||
_ => panic!("a fill first"),
|
||||
}
|
||||
}
|
||||
@@ -183,10 +186,12 @@ fn an_earlier_perspective_strength_draws_as_its_angle() {
|
||||
#[test]
|
||||
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
|
||||
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
|
||||
&& let Some(c) = f.paint.color()
|
||||
{
|
||||
out.push(c.to_rgb());
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() {
|
||||
if let Paint::Gradient(g) = &f.paint {
|
||||
out.extend(g.gradient.stops.iter().map(|s| s.color.to_rgb()));
|
||||
} else if let Some(c) = f.paint.color() {
|
||||
out.push(c.to_rgb());
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
colours(c, out);
|
||||
@@ -265,3 +270,52 @@ fn inflate_reads_twist_taper_and_cap() {
|
||||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let original = faces(&art);
|
||||
assert!(original.len() > 1000);
|
||||
assert!(original.iter().any(|f| f.path_data().unwrap().subpaths.iter().any(|p| p.anchors.len() > 3)), "legacy cells remain polygons");
|
||||
for face in original {
|
||||
assert!(matches!(face.kind, NodeKind::Path { .. }));
|
||||
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
|
||||
assert!((0.05..=0.75).contains(&stroke.width));
|
||||
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
|
||||
}
|
||||
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
|
||||
n.transform(Affine::scale(0.001), false);
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(faces(&art).iter().any(|face| matches!(&face.appearance.items[1], AppearanceItem::Stroke(s) if s.width == 0.05)));
|
||||
// An explicit white light overrides a stale legacy color without switching shading paths.
|
||||
n.appearance.effects[0].params = json!({"lightColor":"#ff0000","lights":[{}]});
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
fn count(n: &Node) -> (usize, usize, usize) {
|
||||
let (mut groups, mut clips, mut paths) = match &n.kind {
|
||||
NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0),
|
||||
NodeKind::Path { .. } => (0, 0, 1),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
for child in n.children().into_iter().flatten() {
|
||||
let (g, c, p) = count(child);
|
||||
groups += g;
|
||||
clips += c;
|
||||
paths += p;
|
||||
}
|
||||
(groups, clips, paths)
|
||||
}
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "exercise a tessellated surface");
|
||||
assert_eq!(clips, 1);
|
||||
assert_eq!(groups, 7, "group count must not grow with triangle count");
|
||||
}
|
||||
@@ -35,6 +35,8 @@ vectorcraft-affinity = { workspace = true }
|
||||
vectorcraft-scriptcheck = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
# The XML parts of Word documents placed as type (place/docx.rs).
|
||||
roxmltree = "0.21"
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -46,6 +48,8 @@ vectorcraft-affinity = { workspace = true, features = ["synth"] }
|
||||
crc32fast = { workspace = true }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
wat = { workspace = true }
|
||||
# Zstandard streams for the Illustrator editing data fuzz tests.
|
||||
ruzstd = { workspace = true }
|
||||
@@ -11,11 +11,12 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_brush::{self as brush, Brush};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::{remove_each, separate};
|
||||
use super::swatch::names_param;
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
@@ -61,18 +62,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Brush…",
|
||||
[],
|
||||
None,
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
|
||||
has_doc,
|
||||
brush_new
|
||||
),
|
||||
cmd!("brush.delete", "Delete Brush", [], None, "{name} delete a brush; strokes using it become plain strokes", has_doc, brush_delete),
|
||||
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} → {name}", has_doc, brush_duplicate),
|
||||
cmd!(
|
||||
"brush.delete",
|
||||
"Delete Brush",
|
||||
[],
|
||||
None,
|
||||
"{name} | {names: [name…]} delete brushes; strokes using them become plain strokes",
|
||||
has_doc,
|
||||
brush_delete
|
||||
),
|
||||
cmd!(
|
||||
"brush.duplicate",
|
||||
"Duplicate Brush",
|
||||
[],
|
||||
None,
|
||||
"{name, newName? (trimmed; blank: <name> copy)} | {names: [name…]} → {name} | {names}",
|
||||
has_doc,
|
||||
brush_duplicate
|
||||
),
|
||||
cmd!(
|
||||
"brush.options",
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName?} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
@@ -101,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Symbol…",
|
||||
[],
|
||||
None,
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
|
||||
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
@@ -110,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Symbol Options…",
|
||||
[],
|
||||
None,
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
|
||||
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}",
|
||||
has_doc,
|
||||
symbol_options
|
||||
),
|
||||
@@ -339,10 +356,10 @@ fn merge(base: &mut Value, patch: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, rename: Option<(String, Option<String>)>) -> Result<()> {
|
||||
fn save_library(s: &mut Session, label: &str, lib: Vec<Brush>, renames: &[(String, Option<String>)]) -> Result<()> {
|
||||
s.edit(label, |d, _| {
|
||||
brush::store(d, &lib);
|
||||
if let Some((from, to)) = rename {
|
||||
for (from, to) in renames {
|
||||
let ids: Vec<NodeId> = {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
@@ -384,7 +401,8 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o => return Err(bad(C, format!("unknown brush type `{o}`"))),
|
||||
};
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]));
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string);
|
||||
let name = brush::unique_name(&lib, &base_name);
|
||||
let mut v = json!({"name": name, "type": ty});
|
||||
if let Some(params) = p.get("params") {
|
||||
@@ -402,7 +420,7 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v[k] = serde_json::to_value(&art).unwrap_or_default();
|
||||
}
|
||||
lib.push(parse_brush(C, v)?);
|
||||
save_library(s, "New Brush", lib, None)?;
|
||||
save_library(s, "New Brush", lib, &[])?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
@@ -414,26 +432,36 @@ fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
|
||||
}
|
||||
|
||||
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, "brush.delete")?.to_string();
|
||||
const C: &str = "brush.delete";
|
||||
let names = names_param(p, C)?;
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let n = lib.len();
|
||||
lib.retain(|b| b.name != name);
|
||||
if lib.len() == n {
|
||||
return Err(bad("brush.delete", format!("no brush named `{name}`")));
|
||||
if let Some(name) = names.iter().find(|n| !lib.iter().any(|b| &b.name == *n)) {
|
||||
return Err(bad(C, format!("no brush named `{name}`")));
|
||||
}
|
||||
save_library(s, "Delete Brush", lib, Some((name, None)))?;
|
||||
lib.retain(|b| !names.contains(&b.name));
|
||||
let renames: Vec<(String, Option<String>)> = names.into_iter().map(|n| (n, None)).collect();
|
||||
save_library(s, "Delete Brush", lib, &renames)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given
|
||||
/// without `names`, names the copy.
|
||||
fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, "brush.duplicate")?;
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
let mut b = lib.iter().find(|b| b.name == name).cloned().ok_or_else(|| bad("brush.duplicate", format!("no brush named `{name}`")))?;
|
||||
b.name = brush::unique_name(&lib, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let out = b.name.clone();
|
||||
lib.push(b);
|
||||
save_library(s, "Duplicate Brush", lib, None)?;
|
||||
Ok(json!({ "name": out }))
|
||||
const C: &str = "brush.duplicate";
|
||||
let names = names_param(p, C)?;
|
||||
let before = brush::library(&s.doc()?.doc);
|
||||
let mut lib = before.clone();
|
||||
let mut out = vec![];
|
||||
for name in &names {
|
||||
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
|
||||
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none());
|
||||
let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string);
|
||||
b.name = brush::unique_name(&lib, &new_name);
|
||||
out.push(b.name.clone());
|
||||
lib.push(b);
|
||||
}
|
||||
save_library(s, "Duplicate Brush", lib, &[])?;
|
||||
Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) })
|
||||
}
|
||||
|
||||
fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -445,7 +473,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(params) = p.get("params") {
|
||||
merge(&mut v, params);
|
||||
}
|
||||
let new_name = str_param(p, "newName").filter(|n| !n.trim().is_empty() && *n != name).map(str::to_string);
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
|
||||
if let Some(nn) = &new_name {
|
||||
if lib.iter().any(|b| &b.name == nn) {
|
||||
return Err(bad(C, format!("a brush named `{nn}` exists")));
|
||||
@@ -458,7 +486,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
lib[i] = parse_brush(C, v)?;
|
||||
let out = lib[i].name.clone();
|
||||
let rename = new_name.map(|nn| (name, Some(nn)));
|
||||
save_library(s, "Brush Options", lib, rename)?;
|
||||
save_library(s, "Brush Options", lib, rename.as_slice())?;
|
||||
Ok(json!({ "name": out }))
|
||||
}
|
||||
|
||||
@@ -540,6 +568,33 @@ fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural
|
||||
/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it
|
||||
/// on with guides `g` (the symbol's current ones, else the centre third, when none are given).
|
||||
fn nine_slice_param(p: &Value, cmd: &str, current: Option<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
|
||||
let on = match p.get("nineSlice") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?),
|
||||
};
|
||||
let guides =
|
||||
match p.get("nineSliceGuides") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| {
|
||||
bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1")
|
||||
})?),
|
||||
};
|
||||
Ok(match (on, guides) {
|
||||
(Some(false), _) => Some(None),
|
||||
(None, None) => None,
|
||||
(_, Some(g)) => Some(Some(g)),
|
||||
(Some(true), None) => Some(Some(current.unwrap_or_default())),
|
||||
})
|
||||
}
|
||||
|
||||
fn nine_slice_json(d: &Document, name: &str) -> Value {
|
||||
d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value)
|
||||
}
|
||||
|
||||
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
|
||||
if from == to {
|
||||
return;
|
||||
@@ -596,6 +651,7 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"size": [w, h],
|
||||
"registration": d.symbol_registration(&sym.name).0,
|
||||
"exportType": d.symbol_export_type(&sym.name).id(),
|
||||
"nineSlice": nine_slice_json(d, &sym.name),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -623,9 +679,10 @@ 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 = s.doc()?.doc.unique_symbol_name(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").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol"));
|
||||
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
|
||||
let export = export_type_param(p, C)?.unwrap_or_default();
|
||||
let nine = nine_slice_param(p, C, None)?.flatten();
|
||||
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
|
||||
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
@@ -634,12 +691,15 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_natural_size(d, &name, Some(size));
|
||||
d.set_symbol_registration(&name, reg);
|
||||
d.set_symbol_export_type(&name, export);
|
||||
d.set_symbol_nine_slice(&name, nine);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() }))
|
||||
Ok(
|
||||
json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }),
|
||||
)
|
||||
}
|
||||
|
||||
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -647,9 +707,10 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
|
||||
let export = export_type_param(p, C)?;
|
||||
let reg = registration_param(p, C)?;
|
||||
let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?;
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() {
|
||||
return Err(bad(C, "pass newName, registration and/or exportType"));
|
||||
if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() {
|
||||
return Err(bad(C, "pass newName, registration, exportType and/or nineSlice"));
|
||||
}
|
||||
if let Some(ref n) = new_name
|
||||
&& n != &name
|
||||
@@ -673,6 +734,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rename_natural_size(d, &name, n);
|
||||
d.rename_symbol_registration(&name, n);
|
||||
d.rename_symbol_export_type(&name, n);
|
||||
d.rename_symbol_nine_slice(&name, n);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
|
||||
}
|
||||
@@ -683,6 +745,9 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(kind) = export {
|
||||
d.set_symbol_export_type(&final_name, kind);
|
||||
}
|
||||
if let Some(g) = nine {
|
||||
d.set_symbol_nine_slice(&final_name, g);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -690,6 +755,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"name": final_name,
|
||||
"registration": d.symbol_registration(&final_name).0,
|
||||
"exportType": d.symbol_export_type(&final_name).id(),
|
||||
"nineSlice": nine_slice_json(d, &final_name),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -848,6 +914,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
set_natural_size(d, &name, None);
|
||||
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
|
||||
d.clear_symbol_export_type(&name);
|
||||
d.set_symbol_nine_slice(&name, None);
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.remove(CURRENT_SYMBOL);
|
||||
}
|
||||
@@ -861,19 +928,22 @@ 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 = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
|
||||
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty());
|
||||
let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string));
|
||||
let size = natural_size(d, &name);
|
||||
let reg = d.symbol_registration(&name);
|
||||
let export = d.symbol_export_type(&name);
|
||||
let nine = d.symbol_nine_slice(&name);
|
||||
s.edit("Duplicate Symbol", |d, _| {
|
||||
let art = d.reid(&sym.art);
|
||||
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &new, Some(size));
|
||||
d.set_symbol_registration(&new, reg);
|
||||
d.set_symbol_export_type(&new, export);
|
||||
d.set_symbol_nine_slice(&new, nine);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
|
||||
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }))
|
||||
}
|
||||
|
||||
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
@@ -88,6 +88,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_or_ids,
|
||||
lp_expand
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.gapOptions",
|
||||
"Gap Options",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}",
|
||||
has_selection_or_ids,
|
||||
lp_gap_options
|
||||
),
|
||||
cmd!(
|
||||
"livePaint.fill",
|
||||
"Live Paint Bucket",
|
||||
@@ -111,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Info",
|
||||
[],
|
||||
None,
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
|
||||
has_doc,
|
||||
lp_info
|
||||
),
|
||||
@@ -120,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
@@ -383,10 +392,11 @@ fn source_nodes(n: &Node) -> Vec<Arc<Node>> {
|
||||
if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] }
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the
|
||||
/// fill, stroke and stroke width of each.
|
||||
fn source_shapes(sources: &[Arc<Node>]) -> (Vec<po::Shape>, Vec<(Paint, Paint, f64)>) {
|
||||
let mut lv = vec![];
|
||||
for n in &sources {
|
||||
for n in sources {
|
||||
b::leaf_shapes(n, &mut lv);
|
||||
}
|
||||
let mut shapes = vec![];
|
||||
@@ -400,6 +410,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
|
||||
}
|
||||
}
|
||||
(shapes, styles)
|
||||
}
|
||||
|
||||
/// Planar map children (sources group, faces, edges) for `sources`; with `max_gap` (Gap
|
||||
/// Detection), paint stops at the gaps up to that wide.
|
||||
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64>) -> Result<(Vec<Arc<Node>>, usize, usize)> {
|
||||
let (shapes, styles) = source_shapes(&sources);
|
||||
if shapes.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: select paths or compound paths".into()));
|
||||
}
|
||||
@@ -410,7 +427,13 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
src_group.visible = false;
|
||||
children.push(Arc::new(src_group));
|
||||
// Every path is an edge, open ones included; faces are the areas they enclose.
|
||||
let (regs, edges) = po::live_paint(&shapes);
|
||||
let (regs, edges) = match max_gap {
|
||||
Some(gap) => {
|
||||
let (regs, edges, _) = po::live_paint_with_gaps(&shapes, gap);
|
||||
(regs, edges)
|
||||
}
|
||||
None => po::live_paint(&shapes),
|
||||
};
|
||||
if regs.is_empty() && edges.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
|
||||
}
|
||||
@@ -433,6 +456,51 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
Ok((children, nfaces, nedges))
|
||||
}
|
||||
|
||||
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
|
||||
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
|
||||
/// appearance, none included, its transparency and the rest) of the old face its inside point is
|
||||
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
|
||||
/// keep the paint of their sources.
|
||||
fn repaint_from(g: &mut Node, old: &Node) {
|
||||
/// How far an edge's middle may be from the old edge it lies on.
|
||||
const ON_EDGE: f64 = 1e-3;
|
||||
let Some(ch) = g.children_mut() else { return };
|
||||
for c in ch.iter_mut() {
|
||||
let Some(pd) = c.path_data() else { continue };
|
||||
let was = match c.name.as_deref() {
|
||||
Some(FACE_NAME) => po::interior_point(pd).and_then(|pt| face_at(old, pt)),
|
||||
Some(EDGE_NAME) => pd
|
||||
.subpaths
|
||||
.first()
|
||||
.filter(|sp| sp.segment_count() > 0)
|
||||
.and_then(|sp| edge_near(old, kurbo::ParamCurve::eval(&sp.segment(0), 0.5), ON_EDGE)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(was) = was {
|
||||
let mut n = (**was).clone();
|
||||
// Gradients fitted to the old outline keep that fit on the new one.
|
||||
n.pin_gradients();
|
||||
n.id = c.id;
|
||||
n.kind = c.kind.clone();
|
||||
n.bbox_angle = c.bbox_angle;
|
||||
*c = Arc::new(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
fn stored_gap_options(options: GapOptions) -> Option<Box<GapOptions>> {
|
||||
(options != GapOptions::default()).then(|| Box::new(options))
|
||||
}
|
||||
|
||||
/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most
|
||||
/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another
|
||||
/// group's is on; else the bottom-most group's.
|
||||
fn merged_gap_options(groups: &[GapOptions]) -> GapOptions {
|
||||
let Some(&bottom) = groups.first() else { return GapOptions::default() };
|
||||
if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom }
|
||||
}
|
||||
|
||||
/// Make a Live Paint group from `roots`; returns (group, faces, edges).
|
||||
fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> {
|
||||
let mut sources = vec![];
|
||||
@@ -440,7 +508,7 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<
|
||||
let n = d.node(*r).ok_or(EngineError::NoNode(*r))?;
|
||||
sources.extend(source_nodes(n));
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
let (children, nf, ne) = build_children(d, sources, None)?;
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some(LIVE_PAINT_NAME.into());
|
||||
@@ -484,11 +552,15 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?));
|
||||
}
|
||||
}
|
||||
let (children, nf, ne) = build_children(d, sources)?;
|
||||
// Merge applies the Gap Options' merge rules to the groups' options, bottom-most first.
|
||||
let groups: Vec<GapOptions> = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect();
|
||||
let options = merged_gap_options(&groups);
|
||||
let (children, nf, ne) = build_children(d, sources, options.max_gap())?;
|
||||
let mut g = Node::group(target, children);
|
||||
g.name = old.name.clone();
|
||||
g.opacity = old.opacity;
|
||||
g.blend = old.blend;
|
||||
g.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
for (pt, paint) in keep_faces {
|
||||
if let Some(fid) = face_at(&g, pt).map(|f| f.id)
|
||||
&& let Some(ch) = g.children_mut()
|
||||
@@ -553,7 +625,11 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for g in &targets {
|
||||
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
|
||||
n.name = None;
|
||||
n.extras_mut().trace = None;
|
||||
{
|
||||
let mut e = n.extras_mut();
|
||||
e.trace = None;
|
||||
e.live_paint_gaps = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
ch.retain(|c| match c.name.as_deref() {
|
||||
Some(SOURCES_NAME) => false,
|
||||
@@ -569,6 +645,81 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to).
|
||||
fn gaps_json(gaps: &[[Point; 2]]) -> Value {
|
||||
json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// A group's Gap Options as `livePaint.info` gives them.
|
||||
fn gap_options_json(o: GapOptions) -> Value {
|
||||
let [r, g, b] = o.color;
|
||||
let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id));
|
||||
json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") })
|
||||
}
|
||||
|
||||
/// The gaps up to `options`' size wide among a Live Paint group's sources.
|
||||
fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> {
|
||||
po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points())
|
||||
}
|
||||
|
||||
fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.gapOptions";
|
||||
let detect = match p.get("detect").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?),
|
||||
None => None,
|
||||
};
|
||||
let size =
|
||||
match p.get("size").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(GapSize::parse(v).ok_or_else(|| {
|
||||
bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let color = match p.get("color").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
|
||||
None => None,
|
||||
};
|
||||
let close = bool_or(p, "closeGaps", false);
|
||||
let targets = live_targets(s, p)?;
|
||||
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
|
||||
let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
|
||||
let mut changed = Beside::replacing(d, &targets);
|
||||
for g in &targets {
|
||||
let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
|
||||
let mut options = old.gap_options();
|
||||
options.detect = detect.unwrap_or(options.detect);
|
||||
options.size = size.unwrap_or(options.size);
|
||||
options.color = color.unwrap_or(options.color);
|
||||
let mut sources = source_nodes(&old);
|
||||
if close {
|
||||
// Close Gaps With Paths adds an unpainted line across each gap to the group's paths.
|
||||
for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) {
|
||||
let line = PathData::single(SubPath::polyline(&gap, false));
|
||||
sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default())));
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
let mut n = old.clone();
|
||||
// The faces change only with what paint stops at, or with new paths.
|
||||
if close || options.max_gap() != old.gap_options().max_gap() {
|
||||
let (children, _, _) = build_children(d, sources, options.max_gap())?;
|
||||
if let Some(ch) = n.children_mut() {
|
||||
*ch = children;
|
||||
}
|
||||
repaint_from(&mut n, &old);
|
||||
}
|
||||
n.extras_mut().live_paint_gaps = stored_gap_options(options);
|
||||
faces += b::faces(&n).len();
|
||||
gaps.extend(open_gaps(&n, options));
|
||||
changed.put(d, *g, vec![n])?;
|
||||
}
|
||||
changed.finish(d)?;
|
||||
sel.set(targets.iter().copied());
|
||||
Ok((faces, gaps, closed))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed }))
|
||||
}
|
||||
|
||||
fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "livePaint.fill";
|
||||
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
|
||||
@@ -637,7 +788,14 @@ fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
|
||||
.collect();
|
||||
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
|
||||
Ok(json!({ "faces": faces, "edges": edges, "sources": sources }))
|
||||
let options = n.gap_options();
|
||||
Ok(json!({
|
||||
"faces": faces,
|
||||
"edges": edges,
|
||||
"sources": sources,
|
||||
"gapOptions": gap_options_json(options),
|
||||
"gaps": gaps_json(&open_gaps(n, options)),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Image Trace ----------
|
||||
@@ -701,9 +859,16 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
|
||||
Ok(library)
|
||||
}
|
||||
|
||||
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
|
||||
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
|
||||
/// refused with advice instead (#525).
|
||||
/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused
|
||||
/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on
|
||||
/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about
|
||||
/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo
|
||||
/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the
|
||||
/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const MAX_TRACE_ANCHORS: usize = 8_000_000;
|
||||
/// The most anchor points an Image Trace may make on the web (see the native limit).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
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`.
|
||||
@@ -817,6 +982,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let library = trace_library(s, &mut params)?;
|
||||
let res = tr::trace_within(&raster, ¶ms, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height));
|
||||
// Pixel space of the decoded raster → the image object's pixel space → document.
|
||||
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
|
||||
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
|
||||
@@ -886,7 +1052,34 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors }))
|
||||
Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors, "warnings": warnings }))
|
||||
}
|
||||
|
||||
/// Images larger than this on a side are traced, but more slowly: the trace says so.
|
||||
pub const LARGE_TRACE_SIDE: u32 = 1500;
|
||||
|
||||
/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that a
|
||||
/// large image takes longer (the public documentation's warning for images over 1500 px), and that
|
||||
/// one over the tracer's [`tr::MAX_PIXELS`] was traced scaled down.
|
||||
pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
if size.0.max(size.1) > LARGE_TRACE_SIDE {
|
||||
out.push(format!(
|
||||
"The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.",
|
||||
size.0, size.1
|
||||
));
|
||||
}
|
||||
if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) {
|
||||
out.push(format!(
|
||||
"The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).",
|
||||
traced.0,
|
||||
traced.1,
|
||||
size.0,
|
||||
size.1,
|
||||
tr::MAX_PIXELS >> 20
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
@@ -955,3 +1148,21 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trace_limit_tests {
|
||||
use super::{MAX_TRACE_ANCHORS, trace_warnings};
|
||||
|
||||
#[test]
|
||||
fn trace_warnings_name_a_large_or_scaled_down_image() {
|
||||
assert!(trace_warnings((1500, 1500), (1500, 1500)).is_empty());
|
||||
assert_eq!(trace_warnings((1501, 10), (1501, 10)).len(), 1);
|
||||
let w = trace_warnings((16000, 12000), (9459, 7094));
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}");
|
||||
}
|
||||
|
||||
// A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit;
|
||||
// the limit still guards memory (about 90 bytes an anchor). Checked when the tests build.
|
||||
const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000);
|
||||
}
|
||||
@@ -109,7 +109,9 @@ impl Clipboard {
|
||||
// The nearest enclosing layer (or sublayer).
|
||||
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
|
||||
}
|
||||
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
|
||||
// Symbol instances are measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b));
|
||||
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
|
||||
let board = bounds.and_then(|b| d.artboard_at(b.center()));
|
||||
Self {
|
||||
@@ -127,9 +129,13 @@ impl Clipboard {
|
||||
Self::gather(src, nodes)
|
||||
}
|
||||
|
||||
/// Union of the objects' visual bounds.
|
||||
/// Union of the objects' visual bounds, with symbol instances measured by the art of the
|
||||
/// symbols the clipboard holds.
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b))
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.symbols = self.symbols.clone();
|
||||
let symbols = d.symbol_boxes();
|
||||
self.nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
|
||||
// reads SVGZ data.
|
||||
"svgz" if unpacks_to_svg(head) => (SVG, SVG),
|
||||
"pdf" => (PDF, PDF),
|
||||
"ps" => (EPS, EPS),
|
||||
"eps" => (EPS, EPS),
|
||||
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
|
||||
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
|
||||
@@ -129,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clipboard as PNG",
|
||||
[],
|
||||
None,
|
||||
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)",
|
||||
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px), warnings} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty); warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
|
||||
always,
|
||||
export_png
|
||||
),
|
||||
@@ -138,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clipboard as PDF",
|
||||
[],
|
||||
None,
|
||||
"{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)",
|
||||
"{} → {dataBase64, warnings} a one-page PDF of the copied objects, the page their visual bounds (dataBase64 null when the clipboard is empty); warnings as for document.export",
|
||||
always,
|
||||
export_pdf
|
||||
),
|
||||
@@ -217,6 +218,9 @@ fn type_only(n: &Node) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG of the copied objects: (bytes, width, height in pixels, the encoder's warnings).
|
||||
pub type Png = (Vec<u8>, u32, u32, Vec<String>);
|
||||
|
||||
impl Clipboard {
|
||||
/// The copied type objects' text, one object per line (`None` unless it holds type only).
|
||||
pub fn text(&self) -> Option<String> {
|
||||
@@ -242,21 +246,21 @@ impl Clipboard {
|
||||
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
|
||||
}
|
||||
|
||||
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
|
||||
/// there are none.
|
||||
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height, the warnings
|
||||
/// [`fileio::encode_with_warnings`] gives); `None` when there are none.
|
||||
pub fn png(&self, scale: f64) -> Result<Option<Png>> {
|
||||
const C: &str = "clipboard.exportPng";
|
||||
let Some((doc, b)) = self.cropped() else { return Ok(None) };
|
||||
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
|
||||
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
|
||||
Ok(Some((png, w, h)))
|
||||
let (png, warnings) = fileio::encode_with_warnings(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
|
||||
Ok(Some((png, w, h, warnings)))
|
||||
}
|
||||
|
||||
/// A one-page PDF of the objects (without the native document inside); `None` when there are
|
||||
/// none.
|
||||
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
/// A one-page PDF of the objects (without the native document inside) → (bytes, the encoder's
|
||||
/// warnings); `None` when there are none.
|
||||
pub fn pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
|
||||
let Some((doc, _)) = self.cropped() else { return Ok(None) };
|
||||
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::flavours`], without making them.
|
||||
@@ -274,23 +278,38 @@ impl Clipboard {
|
||||
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
|
||||
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
|
||||
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
|
||||
self.flavours_with_warnings(svg, pdf).0
|
||||
}
|
||||
|
||||
/// [`Self::flavours`], with the warnings of the PDF and PNG encoders, each once (an image that
|
||||
/// couldn't be decoded, type in the fallback font).
|
||||
pub fn flavours_with_warnings(&self, svg: bool, pdf: bool) -> (Vec<Flavour>, Vec<String>) {
|
||||
let types = self.flavour_types(svg, pdf);
|
||||
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
|
||||
let mut out = vec![];
|
||||
let mut warnings: Vec<String> = vec![];
|
||||
let mut keep = |(bytes, notes): (Vec<u8>, Vec<String>)| {
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
bytes
|
||||
};
|
||||
for mime in types {
|
||||
let data = match mime {
|
||||
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
|
||||
SVG => svg.clone().map(String::into_bytes),
|
||||
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
|
||||
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten().map(&mut keep),
|
||||
_ => {
|
||||
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
|
||||
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
|
||||
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
|
||||
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, _, _, notes)| keep((png, notes)))
|
||||
}
|
||||
};
|
||||
out.extend(data.map(|data| Flavour { mime, data }));
|
||||
}
|
||||
out
|
||||
(out, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,15 +320,15 @@ impl Session {
|
||||
self.clipboard.text()
|
||||
}
|
||||
|
||||
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
|
||||
/// when the clipboard is empty.
|
||||
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height, the
|
||||
/// encoder's warnings); `None` when the clipboard is empty.
|
||||
pub fn clipboard_png(&self, scale: f64) -> Result<Option<Png>> {
|
||||
self.clipboard.png(scale)
|
||||
}
|
||||
|
||||
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
|
||||
/// clipboard is empty.
|
||||
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
/// A one-page PDF of the copied objects (without the native document inside) → (bytes, the
|
||||
/// encoder's warnings); `None` when the clipboard is empty.
|
||||
pub fn clipboard_pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
|
||||
self.clipboard.pdf()
|
||||
}
|
||||
|
||||
@@ -355,13 +374,18 @@ fn export_png(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64"));
|
||||
}
|
||||
Ok(match s.clipboard_png(scale)? {
|
||||
Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }),
|
||||
Some((png, w, h, warnings)) => {
|
||||
json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h, "warnings": warnings })
|
||||
}
|
||||
None => json!({ "dataBase64": null }),
|
||||
})
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) }))
|
||||
Ok(match s.clipboard_pdf()? {
|
||||
Some((pdf, warnings)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&pdf), "warnings": warnings }),
|
||||
None => json!({ "dataBase64": null }),
|
||||
})
|
||||
}
|
||||
|
||||
fn export_text(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -418,7 +442,8 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) {
|
||||
// Programs whose header is only `%!` are read too, as they are when a `.ps` or `.eps` file opens.
|
||||
if !bytes.starts_with(b"%!") && !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)
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
|
||||
|
||||
use super::fileio::{default_name, encode, isolated, write_file};
|
||||
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
@@ -100,12 +100,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let css = format!("{}\n", sheet.text());
|
||||
let doc = &s.doc()?.doc;
|
||||
// The pictures of rasterized art.
|
||||
// Encode the pictures of rasterized art, and add each of their warnings once.
|
||||
let mut images = Vec::new();
|
||||
let mut warnings = sheet.warnings.clone();
|
||||
for r in &sheet.rules {
|
||||
let Some(name) = &r.image else { continue };
|
||||
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
|
||||
images.push((name, encode(&d, "png", &json!({}))?));
|
||||
let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?;
|
||||
images.push((name, png));
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = sheet.rules.len();
|
||||
match str_param(p, "path") {
|
||||
@@ -118,12 +125,12 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_file(&at, bytes)?;
|
||||
written.push(at);
|
||||
}
|
||||
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
|
||||
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": warnings }))
|
||||
}
|
||||
None => {
|
||||
let images: Vec<Value> =
|
||||
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
|
||||
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": warnings }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,13 +746,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
|
||||
/// never saved).
|
||||
/// never saved, [`crate::DocState::change_unsaved`]).
|
||||
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
|
||||
let clean = !st.is_dirty();
|
||||
Arc::make_mut(&mut st.doc).puppet = None;
|
||||
if clean {
|
||||
st.mark_saved();
|
||||
}
|
||||
st.change_unsaved(|d| Arc::make_mut(d).puppet = None);
|
||||
}
|
||||
|
||||
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1335,7 +1331,8 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
|
||||
/// in every undo state (the flat view is never an undo step of its own).
|
||||
/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved
|
||||
/// state stays saved, [`crate::DocState::change_unsaved`]).
|
||||
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
let clear = |doc: &mut Arc<Document>| {
|
||||
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
|
||||
@@ -1346,14 +1343,14 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
p.editing = false;
|
||||
}
|
||||
};
|
||||
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
|
||||
clear(&mut st.doc);
|
||||
if saved {
|
||||
st.saved_doc = st.doc.clone();
|
||||
}
|
||||
st.change_unsaved(clear);
|
||||
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
|
||||
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
|
||||
clear(&mut e.doc);
|
||||
e.doc.drop_lookup_cache();
|
||||
if saved {
|
||||
st.saved_doc = e.doc.clone();
|
||||
}
|
||||
}
|
||||
if let Some(it) = &mut st.interaction {
|
||||
clear(&mut it.doc);
|
||||
|
||||
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Lock Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+;"),
|
||||
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
|
||||
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
|
||||
has_doc,
|
||||
lock_guides
|
||||
),
|
||||
@@ -601,12 +601,20 @@ fn release_guides(s: &mut Session, p: &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, ruler guides or guide paths ([`Session::select`] lets
|
||||
// those go).
|
||||
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
|
||||
// ([`Session::select`] lets those go in the active one).
|
||||
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())?;
|
||||
}
|
||||
if v {
|
||||
let active = s.active;
|
||||
for (i, st) in s.docs.iter_mut().enumerate() {
|
||||
if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value
|
||||
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
|
||||
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
|
||||
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
|
||||
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
|
||||
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?,
|
||||
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
|
||||
"backgroundContents" => s.background = background_param(v, C)?,
|
||||
// Read-only values `document.setup` reports.
|
||||
@@ -223,8 +223,8 @@ fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetric
|
||||
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
|
||||
for (key, x) in o {
|
||||
match key.as_str() {
|
||||
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
|
||||
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
|
||||
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, vectorcraft_doc::setup::MAX_SCRIPT)?,
|
||||
"position" => m.position = percent(&format!("{k}.position"), x, -vectorcraft_doc::setup::MAX_SCRIPT, vectorcraft_doc::setup::MAX_SCRIPT)?,
|
||||
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(O
|
||||
}
|
||||
|
||||
/// A superscript or subscript in `setup`'s proportions.
|
||||
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
pub(crate) fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
match p {
|
||||
CharPosition::Normal => CharPosition::Normal,
|
||||
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Segment",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath, segment, t, dx, dy} bend a segment so the point at t moves by (dx, dy)",
|
||||
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
|
||||
has_doc,
|
||||
reshape_segment
|
||||
),
|
||||
@@ -201,6 +201,16 @@ fn subpath_len(sp: &SubPath) -> f64 {
|
||||
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
|
||||
}
|
||||
|
||||
/// The bound, in points, on the length of a `path.reshapeSegment` delta and on the x and y of a
|
||||
/// point given to `path.convertAnchor` or `path.freehand`. The blend spine commands set the same
|
||||
/// bound (`object.blend.spine.reshapeSegment`, `object.blend.spine.moveAnchor`).
|
||||
const MAX_INPUT: f64 = 1e9;
|
||||
|
||||
/// Whether `q` is finite and its x and y are each smaller than [`MAX_INPUT`] in size.
|
||||
fn within_input_bound(q: Point) -> bool {
|
||||
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
|
||||
}
|
||||
|
||||
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
@@ -541,6 +551,9 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
if samples.iter().any(|(q, _)| !within_input_bound(*q)) {
|
||||
return Err(bad(C, "x and y must be finite"));
|
||||
}
|
||||
let pressure = stroke_pressure(&samples);
|
||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
|
||||
@@ -857,6 +870,9 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Some(x), Some(y)) => Some(Point::new(x, y)),
|
||||
_ => None,
|
||||
};
|
||||
if target.is_some_and(|q| !within_input_bound(q)) {
|
||||
return Err(bad(C, "x and y must be finite"));
|
||||
}
|
||||
s.edit("Convert Anchor Point", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
@@ -886,13 +902,15 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg = usize_req(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
if !(dv.x.is_finite() && dv.y.is_finite()) {
|
||||
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
|
||||
return Err(bad(C, "invalid delta"));
|
||||
}
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if !sp.bend_segment(seg, t, dv) {
|
||||
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
|
||||
if !bent {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+110
-25
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Unit};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use super::clipboard::SwatchChoices;
|
||||
@@ -37,13 +37,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
set_units
|
||||
),
|
||||
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
|
||||
cmd!(
|
||||
query "document.rulerOrigin",
|
||||
"Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{artboard?: index=0 (the active artboard, which artboard rulers measure from)} → {x, y} the rulers' zero point, in absolute document points",
|
||||
has_doc,
|
||||
ruler_origin
|
||||
),
|
||||
cmd!(
|
||||
"document.setRulerOrigin",
|
||||
"Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
|
||||
"{x, y, artboard?: index=0} set the rulers' zero point in absolute document points: the document's with global rulers, that artboard's with artboard rulers (it moves with the artboard); art, guides and command coordinates stay in place",
|
||||
has_doc,
|
||||
set_ruler_origin
|
||||
),
|
||||
@@ -52,10 +60,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Ruler Origin",
|
||||
[],
|
||||
None,
|
||||
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
|
||||
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner (with artboard rulers, that artboard's own zero point)",
|
||||
has_doc,
|
||||
reset_ruler_origin
|
||||
),
|
||||
cmd!(
|
||||
"view.globalRulers",
|
||||
"Change to Global Rulers",
|
||||
["View", "Rulers"],
|
||||
Some("Cmd+Alt+R"),
|
||||
"{global?: bool (default: toggle)} global rulers measure the whole document from one zero point; artboard rulers (a new document's) from the active artboard's top-left corner, or the zero point set for it. Saved with the document → {global}",
|
||||
has_doc,
|
||||
global_rulers
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
@@ -229,33 +246,91 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
|
||||
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
|
||||
};
|
||||
if index >= s.doc()?.doc.artboards.len() {
|
||||
return Err(bad(c, "index out of range"));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
// A document without artboards measures from its global zero point.
|
||||
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
|
||||
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
|
||||
}
|
||||
|
||||
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.setRulerOrigin";
|
||||
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
change_ruler_origin(s, origin, "Ruler Origin")
|
||||
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
|
||||
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
|
||||
}
|
||||
|
||||
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.resetRulerOrigin";
|
||||
let index = match p.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
|
||||
};
|
||||
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
|
||||
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
|
||||
let index = artboard_param(s, p, "document.resetRulerOrigin")?;
|
||||
change_ruler_origin(s, index, None, "Reset Ruler Origin")
|
||||
}
|
||||
|
||||
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
|
||||
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
|
||||
/// Move the rulers' zero point to `origin`, or back to artboard `index`'s top-left corner: the
|
||||
/// document's (global rulers) or that artboard's (artboard rulers, kept as an offset from its
|
||||
/// corner so it follows the artboard).
|
||||
fn change_ruler_origin(s: &mut Session, index: usize, origin: Option<Point>, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let global = d.rulers == RulerMode::Global;
|
||||
let corner = d.artboards.get(index).map(|ab| Point::new(ab.rect.x0, ab.rect.y0));
|
||||
let target = origin.or(corner).unwrap_or(Point::ZERO);
|
||||
if !target.is_finite() || target.x.abs() > crate::MAX_COORD || target.y.abs() > crate::MAX_COORD {
|
||||
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
|
||||
}
|
||||
if s.doc()?.doc.ruler_origin != origin {
|
||||
if global {
|
||||
if d.ruler_origin != target {
|
||||
s.edit(label, |d, _| {
|
||||
d.ruler_origin = target;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
return Ok(json!(target));
|
||||
}
|
||||
let Some((id, corner)) = d.artboards.get(index).map(|ab| ab.id).zip(corner) else {
|
||||
return Err(bad("document.setRulerOrigin", "index out of range"));
|
||||
};
|
||||
let offset = Some(target - corner).filter(|o| *o != Vec2::ZERO);
|
||||
if d.artboard_ruler_origins.get(&id).copied() != offset {
|
||||
s.edit(label, |d, _| {
|
||||
d.ruler_origin = origin;
|
||||
// Origins of artboards that no longer exist go too.
|
||||
let ids: BTreeSet<u32> = d.artboards.iter().map(|ab| ab.id).collect();
|
||||
d.artboard_ruler_origins.retain(|id, _| ids.contains(id));
|
||||
match offset {
|
||||
Some(o) => d.artboard_ruler_origins.insert(id, o),
|
||||
None => d.artboard_ruler_origins.remove(&id),
|
||||
};
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!(origin))
|
||||
Ok(json!(target))
|
||||
}
|
||||
|
||||
/// View › Rulers › Change to Global / Artboard Rulers.
|
||||
fn global_rulers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let current = s.doc()?.doc.rulers == RulerMode::Global;
|
||||
let global = match p.get("global") {
|
||||
None => !current,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("view.globalRulers", "global must be true or false"))?,
|
||||
};
|
||||
if global != current {
|
||||
let label = if global { "Change to Global Rulers" } else { "Change to Artboard Rulers" };
|
||||
s.edit(label, |d, _| {
|
||||
d.rulers = if global { RulerMode::Global } else { RulerMode::Artboard };
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "global": global }))
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
@@ -296,6 +371,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -308,12 +384,17 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
// Guides locked since the step was made stay deselected.
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "undone": label }))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
||||
let label = e.label.clone();
|
||||
@@ -326,6 +407,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
super::maskedit::follow_history(st, editing);
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
@@ -344,22 +428,23 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
|
||||
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
|
||||
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
|
||||
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
|
||||
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
|
||||
/// rest still go to the compound path, which holds the paint.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true))
|
||||
}
|
||||
|
||||
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
|
||||
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
|
||||
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
roots_within(doc, ids, None)
|
||||
roots_within(doc, ids, false)
|
||||
}
|
||||
|
||||
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
|
||||
/// [`roots_of`]; with `members`, a compound path's member listed stays itself.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> Vec<NodeId> {
|
||||
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
// A compound-path member acts as its compound (compounds are one object).
|
||||
@@ -369,7 +454,7 @@ fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) ->
|
||||
let path = paths.get(&id);
|
||||
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
|
||||
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())),
|
||||
_ => (id, path.cloned()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -426,9 +426,10 @@ fn is_container(n: &Node) -> bool {
|
||||
}
|
||||
|
||||
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at
|
||||
/// the document's raster effects resolution, with whether they all paint below it (shadows and
|
||||
/// outer glows: the image then holds just them). A group's or layer's image leaves out its
|
||||
/// members' raster effects, which are expanded with them. `None` when it has none.
|
||||
/// the document's raster effects resolution or a Rasterize effect's finer one, with whether they
|
||||
/// all paint below it (shadows and outer glows: the image then holds just them). A group's or
|
||||
/// layer's image leaves out its members' raster effects, which are expanded with them. `None`
|
||||
/// when it has none.
|
||||
fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
|
||||
let fx = rasterfx::raster_fx(n);
|
||||
if fx.is_empty() {
|
||||
@@ -551,6 +552,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
@@ -562,7 +564,12 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
let mut out = json!({ "ids": ids_json(&ids) });
|
||||
// Raster effects become images rendered from the art.
|
||||
if let Some(w) = rasterfx::undecoded_warning(failed) {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+419
-13
@@ -1,8 +1,9 @@
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
|
||||
//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
|
||||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient and pattern
|
||||
//! fills (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
|
||||
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,13 +13,14 @@ use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::pattern::{PatternDef, place_art};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, replace_leaves, styled_copy, with_transparency};
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::*;
|
||||
|
||||
const C: &str = "object.expand";
|
||||
@@ -37,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -46,11 +48,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke} (true: something to expand)",
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke}))
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient}))
|
||||
}
|
||||
),
|
||||
]
|
||||
@@ -99,6 +101,8 @@ struct Expandable {
|
||||
live: bool,
|
||||
effects: bool,
|
||||
fill: bool,
|
||||
/// Gradient fills (the ones Expand Gradient To applies to), a part of `fill`.
|
||||
gradient: bool,
|
||||
stroke: bool,
|
||||
/// An envelope (Object expands it into its distorted content).
|
||||
envelope: bool,
|
||||
@@ -127,10 +131,17 @@ impl Expandable {
|
||||
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
|
||||
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.fill |= match &c.kind {
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => {
|
||||
t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some()))
|
||||
}
|
||||
_ => shape && c.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()),
|
||||
};
|
||||
});
|
||||
}
|
||||
e
|
||||
@@ -152,6 +163,17 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
|
||||
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) if f.visible => match &f.paint {
|
||||
Paint::Pattern { pattern, xf } => Some((f, d.pattern(pattern)?, *xf)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = bool_or(p, "object", true);
|
||||
let fill = bool_or(p, "fill", true);
|
||||
@@ -212,6 +234,7 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if fill {
|
||||
let _ = expand_gradient_fills(s, to);
|
||||
let _ = expand_pattern_fills(s);
|
||||
}
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
@@ -250,18 +273,27 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Path `l` with each gradient fill turned into art, in appearance order: the other items stay
|
||||
/// on plain copies of the path. `None` when it has no gradient fill.
|
||||
/// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own
|
||||
/// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no
|
||||
/// gradient fill can be expanded.
|
||||
fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let (path, _) = node_path(l)?;
|
||||
let bounds = path.bounds()?;
|
||||
let mut parts = vec![];
|
||||
let mut rest = vec![];
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let Some(art) = gradient_fill(item).and_then(|(f, g)| gradient_art(d, l, &path, bounds, f, g, to)) else {
|
||||
let art = gradient_fill(item).and_then(|(f, g)| {
|
||||
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
gradient_art(d, &fl, &path, bounds, f, g, to)
|
||||
});
|
||||
let Some(art) = art else {
|
||||
if item.visible() && !item.paint().is_none() {
|
||||
rest.push(item.clone());
|
||||
}
|
||||
@@ -271,6 +303,10 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
|
||||
}
|
||||
parts.push(art);
|
||||
expanded = true;
|
||||
}
|
||||
if !expanded {
|
||||
return None;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, rest));
|
||||
@@ -279,7 +315,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// Gradient fill `f` (gradient `g`) of path `l` (outline `path`, box `bounds`) as art, with the
|
||||
/// fill's opacity, blend mode and effects.
|
||||
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
|
||||
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
|
||||
let content = if g.gradient.kind == GradientKind::Freeform {
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
|
||||
@@ -308,7 +344,377 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
|
||||
};
|
||||
let mut art = with_transparency(d, art, f.opacity, f.blend);
|
||||
art.appearance.effects = f.effects.clone();
|
||||
art.appearance.effects = fill_effects(f);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// visible geometry effect.
|
||||
fn reshaped(l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
}
|
||||
let (path, _) = node_path(l)?;
|
||||
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
|
||||
let mut m = with_path(l, g);
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
|
||||
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
|
||||
/// as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
|
||||
if !has_geometry(&f.effects) {
|
||||
return Some((l.clone(), path.clone(), bounds));
|
||||
}
|
||||
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
|
||||
let b = outline.bounds()?;
|
||||
Some((with_path(l, outline.clone()), outline, b))
|
||||
}
|
||||
|
||||
/// Path or compound path `l` with `path` as its path, a plain path.
|
||||
fn with_path(l: &Node, path: PathData) -> Node {
|
||||
let mut m = l.clone();
|
||||
m.kind = match &l.kind {
|
||||
NodeKind::Path { rule, clipping, guide, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: *clipping, guide: *guide },
|
||||
NodeKind::Compound { rule, .. } => NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false },
|
||||
k => k.clone(),
|
||||
};
|
||||
m
|
||||
}
|
||||
|
||||
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
|
||||
/// shape the outline it paints ([`fill_shape`]).
|
||||
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
|
||||
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
|
||||
}
|
||||
|
||||
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
|
||||
fn expand_pattern_fills(s: &mut Session) -> Result<()> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, sel| {
|
||||
let (mut ids, mut changed) = (vec![], 0);
|
||||
for r in &roots {
|
||||
let (id, n) = replace_leaves(d, *r, expand_patterns)?;
|
||||
ids.push(id);
|
||||
changed += n;
|
||||
}
|
||||
if changed == 0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
sel.set(ids);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it
|
||||
/// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects
|
||||
/// other than geometry effects, in appearance order: the other items stay on plain copies of the
|
||||
/// path. The path's geometry effects ([`reshaped`]) and each fill's own ([`fill_shape`]) shape
|
||||
/// that outline first, as on the canvas. The tiles are drawn as the canvas draws them
|
||||
/// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill
|
||||
/// lays a tile.
|
||||
fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let reshaped = reshaped(l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let (path, _) = node_path(l)?;
|
||||
let bounds = path.bounds()?;
|
||||
let mut parts = vec![];
|
||||
let mut rest = vec![];
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
|
||||
let tiles = fill.and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
|
||||
let t = pattern_tiles(d, &def, xf, bounds);
|
||||
(!t.is_empty()).then_some((f, fl, t))
|
||||
});
|
||||
let Some(art) = tiles.and_then(|(f, fl, t)| tile_art(d, &fl, f, &t)) else {
|
||||
if item.visible() && !item.paint().is_none() {
|
||||
rest.push(item.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
|
||||
}
|
||||
parts.push(art);
|
||||
expanded = true;
|
||||
}
|
||||
if !expanded {
|
||||
return None;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, rest));
|
||||
}
|
||||
assemble(d, l, parts)
|
||||
}
|
||||
|
||||
/// The tiles pattern `def` placed by `xf` lays over `bounds` in document `d`, as the canvas draws
|
||||
/// them: their stroke weights and effects scale with the pattern, and when it also turns, flips,
|
||||
/// stretches or shears them, the strokes (brushed ones included) and geometry effects of their
|
||||
/// paths and opacity masks become filled art and their live shapes become plain paths first. The
|
||||
/// same happens when it scales art with geometry effects (Roughen's detail, for one, is per inch
|
||||
/// and does not scale). No tiles when it turns, flips, stretches or shears art with raster or 3D
|
||||
/// effects or with a geometry effect on a group or type, or scales art with a geometry effect on a
|
||||
/// group or type, which the canvas draws turned or scaled with the tile and live effects would
|
||||
/// not, and when it does more than move art that holds a symbol instance, whose strokes the canvas
|
||||
/// draws scaled with the tile and an instance does not scale. Random Transform effects and brushes
|
||||
/// that vary at random in the art are baked first ([`seeds_baked`], [`random_brushes_baked`]).
|
||||
fn pattern_tiles(d: &mut Document, def: &PatternDef, xf: Affine, bounds: Rect) -> Vec<Node> {
|
||||
if !moves_only(xf) && def.art.iter().any(|a| has_instances(a)) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(def) = seeds_baked(def) else {
|
||||
return vec![];
|
||||
};
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let def = random_brushes_baked(d, &brushes, def);
|
||||
let def = &*def;
|
||||
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_render::effects::scale_effect), patterns: true, ..Scaling::default() };
|
||||
if scales_evenly(xf) && (moves_only(xf) || !def.art.iter().any(|a| has_geometry_effects(a))) {
|
||||
return def.instances_in_with(xf, bounds, scaling);
|
||||
}
|
||||
if def.art.iter().any(|a| has_unbakeable_effects(a)) {
|
||||
return vec![];
|
||||
}
|
||||
let placed = Scaling { patterns: true, ..Scaling::default() };
|
||||
let mut tiles = def.instances_in_with(Affine::IDENTITY, xf.inverse().transform_rect_bbox(bounds), placed);
|
||||
for t in &mut tiles {
|
||||
*t = prepared(d, &brushes, t);
|
||||
place_art(t, xf, scaling);
|
||||
}
|
||||
tiles
|
||||
}
|
||||
|
||||
/// Pattern `def` with each path in its art (opacity masks included) that has a random Transform
|
||||
/// effect baked into filled art ([`bake_appearance`](vectorcraft_render::effects::bake_appearance))
|
||||
/// while it keeps its own id: the effect takes its seed from the id, and the canvas draws the art
|
||||
/// with those ids for every tile, while the tiles get ids of their own. `None` when something
|
||||
/// other than a path has such an effect.
|
||||
fn seeds_baked(def: &PatternDef) -> Option<std::borrow::Cow<'_, PatternDef>> {
|
||||
if !def.art.iter().any(|a| has_seeded(a)) {
|
||||
return Some(std::borrow::Cow::Borrowed(def));
|
||||
}
|
||||
let mut def = def.clone();
|
||||
def.art = def.art.iter().map(|a| bake_seeded(a).map(Arc::new)).collect::<Option<_>>()?;
|
||||
Some(std::borrow::Cow::Owned(def))
|
||||
}
|
||||
|
||||
/// Pattern `def` with each path in its art (opacity masks included) whose stroke has a brush that
|
||||
/// varies at random ([`draws_random_art`]) [`prepared`] where the pattern defines it: the brush
|
||||
/// takes its seed from the path's points, and the canvas draws the art there for every tile.
|
||||
fn random_brushes_baked<'a>(
|
||||
d: &mut Document,
|
||||
brushes: &[vectorcraft_brush::Brush],
|
||||
def: std::borrow::Cow<'a, PatternDef>,
|
||||
) -> std::borrow::Cow<'a, PatternDef> {
|
||||
if !def.art.iter().any(|a| has_random_brush(brushes, a)) {
|
||||
return def;
|
||||
}
|
||||
let mut def = def.into_owned();
|
||||
def.art = def.art.iter().map(|a| Arc::new(bake_random_brushes(d, brushes, a))).collect();
|
||||
std::borrow::Cow::Owned(def)
|
||||
}
|
||||
|
||||
/// Does brush `name` of `brushes` draw different art for different points: a calligraphic or
|
||||
/// scatter brush with Random variation, or a bristle brush?
|
||||
fn draws_random_art(brushes: &[vectorcraft_brush::Brush], name: &str) -> bool {
|
||||
use vectorcraft_brush::{BrushKind, Variation};
|
||||
brushes.iter().find(|b| b.name == name).is_some_and(|b| match &b.kind {
|
||||
BrushKind::Calligraphic(c) => c.modes().contains(&Variation::Random),
|
||||
BrushKind::Scatter(s) => s.modes().contains(&Variation::Random),
|
||||
BrushKind::Bristle(_) => true,
|
||||
BrushKind::Art(_) | BrushKind::Pattern(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `n` a path with a visible stroke whose brush varies at random ([`draws_random_art`])?
|
||||
fn random_brushed(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
|
||||
let random = |st: &vectorcraft_doc::appearance::StrokeLayer| st.visible && st.brush.as_deref().is_some_and(|b| draws_random_art(brushes, b));
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if random(st)))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a stroke whose brush varies at
|
||||
/// random ([`random_brushed`])?
|
||||
fn has_random_brush(brushes: &[vectorcraft_brush::Brush], n: &Node) -> bool {
|
||||
random_brushed(brushes, n)
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_random_brush(brushes, &m.art))
|
||||
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_random_brush(brushes, c))))
|
||||
}
|
||||
|
||||
/// `n` with the paths in it whose brush varies at random [`prepared`] ([`random_brushes_baked`]).
|
||||
fn bake_random_brushes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
|
||||
if !has_random_brush(brushes, n) {
|
||||
return n.clone();
|
||||
}
|
||||
if random_brushed(brushes, n) {
|
||||
return prepared(d, brushes, n);
|
||||
}
|
||||
let mut n = n.clone();
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(bake_random_brushes(d, brushes, c));
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(bake_random_brushes(d, brushes, &m.art));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a visible geometry effect?
|
||||
fn has_geometry_effects(n: &Node) -> bool {
|
||||
let geometry = |e: &vectorcraft_doc::Effect| e.visible && vectorcraft_render::effects::is_geometry(&e.id);
|
||||
n.appearance.effects.iter().any(geometry)
|
||||
|| n.appearance.items.iter().any(|i| i.effects().iter().any(geometry))
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_geometry_effects(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_geometry_effects(c)))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks hold a symbol instance?
|
||||
fn has_instances(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::SymbolInstance { .. })
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_instances(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_instances(c)))
|
||||
}
|
||||
|
||||
/// Does `a` only move (no scale, turn, flip, stretch or shear)?
|
||||
fn moves_only(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-9;
|
||||
(m0 - 1.0).abs() <= eps && m1.abs() <= eps && m2.abs() <= eps && (m3 - 1.0).abs() <= eps
|
||||
}
|
||||
|
||||
/// Does `n` have a visible Transform effect with Random on, its own or a fill's or stroke's?
|
||||
fn seeded_here(n: &Node) -> bool {
|
||||
let seeded =
|
||||
|e: &vectorcraft_doc::Effect| e.visible && e.id == "distort.transform" && e.params.get("random").and_then(Value::as_bool) == Some(true);
|
||||
n.appearance.effects.iter().any(seeded) || n.appearance.items.iter().any(|i| i.effects().iter().any(seeded))
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a random Transform effect
|
||||
/// ([`seeded_here`])?
|
||||
fn has_seeded(n: &Node) -> bool {
|
||||
seeded_here(n)
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art))
|
||||
|| (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c))))
|
||||
}
|
||||
|
||||
/// `n` with the paths in it that have a random Transform effect baked ([`seeds_baked`]). `None`
|
||||
/// when something else has one.
|
||||
fn bake_seeded(n: &Node) -> Option<Node> {
|
||||
if !has_seeded(n) {
|
||||
return Some(n.clone());
|
||||
}
|
||||
let mut n = if seeded_here(n) {
|
||||
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
vectorcraft_render::effects::bake_appearance(n)?
|
||||
} else {
|
||||
n.clone()
|
||||
};
|
||||
if !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(bake_seeded(c)?);
|
||||
}
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(bake_seeded(&m.art)?);
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// Does `n`, something in it or one of their opacity masks have a visible effect that [`prepared`]
|
||||
/// cannot bake: a raster or 3D effect, or a geometry effect on something other than a path?
|
||||
fn has_unbakeable_effects(n: &Node) -> bool {
|
||||
use vectorcraft_render::effects::{is_geometry, is_raster, is_three_d};
|
||||
let path = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
let unbakeable = |e: &vectorcraft_doc::Effect| e.visible && (is_raster(&e.id) || is_three_d(&e.id) || (!path && is_geometry(&e.id)));
|
||||
n.appearance.effects.iter().any(unbakeable)
|
||||
|| n.appearance.items.iter().any(|i| i.effects().iter().any(unbakeable))
|
||||
|| n.extras().mask.as_ref().is_some_and(|m| has_unbakeable_effects(&m.art))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| has_unbakeable_effects(c)))
|
||||
}
|
||||
|
||||
/// Tile art `n` ready for a turn, flip, stretch or shear: the strokes and geometry effects of its
|
||||
/// paths baked ([`baked`], [`outline_brushed`]) and its live shapes turned into plain paths (a
|
||||
/// stretch would rebuild their corners round), in its opacity masks too.
|
||||
fn prepared(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node) -> Node {
|
||||
let mut t = baked(n);
|
||||
outline_brushed(d, brushes, &mut t);
|
||||
drop_live(&mut t);
|
||||
prepare_masks(d, brushes, &mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// The opacity masks of `n` and of everything in it [`prepared`].
|
||||
fn prepare_masks(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
m.art = Arc::new(prepared(d, brushes, &m.art));
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
prepare_masks(d, brushes, Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with each path in it that has a brushed stroke turned into filled art and brush art
|
||||
/// ([`outline_strokes`]).
|
||||
fn outline_brushed(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &mut Node) {
|
||||
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||
let brushed = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some()));
|
||||
if brushed && let Some(o) = outline_strokes(d, brushes, n) {
|
||||
*n = o;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
outline_brushed(d, brushes, Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move and a positive uniform scale (which brush nibs, strokes and effects follow by
|
||||
/// scaling alone)?
|
||||
fn scales_evenly(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let eps = 1e-9 * m0.abs().max(m3.abs());
|
||||
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
|
||||
}
|
||||
|
||||
/// `n` with the strokes and geometry effects of each path in it baked into filled art
|
||||
/// ([`bake_appearance`](vectorcraft_render::effects::bake_appearance)).
|
||||
fn baked(n: &Node) -> Node {
|
||||
if let Some(b) = vectorcraft_render::effects::bake_appearance(n) {
|
||||
return b;
|
||||
}
|
||||
let mut n = n.clone();
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
*c = Arc::new(baked(c));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Pattern tiles `tiles` of fill `f` of path `l` as a clip group shaped like `l` that isolates
|
||||
/// their blending (as the canvas draws a tile on its own), with the fill's opacity, blend mode and
|
||||
/// effects other than geometry effects ([`fill_effects`]).
|
||||
fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option<Node> {
|
||||
let mut clip = styled_copy(d, l, vec![]);
|
||||
as_clipping_path(&mut clip).ok()?;
|
||||
let tiles: Vec<Node> = tiles.iter().map(|t| d.reid(t)).collect();
|
||||
let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect();
|
||||
let mut art = Node::new(d.alloc_id(), NodeKind::Group { children, clip: true });
|
||||
art.isolate = true;
|
||||
let mut art = with_transparency(d, art, f.opacity, f.blend);
|
||||
art.appearance.effects = fill_effects(f);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
|
||||
@@ -140,8 +140,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
|
||||
}
|
||||
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
|
||||
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
|
||||
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
|
||||
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
|
||||
// destination uses the same name). `file_part` writes the characters Windows does not allow in a
|
||||
// file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) in the data set's
|
||||
// name as `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
|
||||
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
|
||||
let steps = filter_steps(run.steps, true, false);
|
||||
let run = Run { steps: &steps, ..*run };
|
||||
@@ -246,7 +247,8 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
|
||||
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
|
||||
/// extension of the step's `format` ([`format_extension`]), else `default_ext`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rewrite_paths(
|
||||
steps: &[Value],
|
||||
@@ -271,8 +273,10 @@ fn rewrite_paths(
|
||||
}
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
let mut params = params.as_object().cloned().unwrap_or_default();
|
||||
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
|
||||
let ext = if ext.is_empty() { default_ext } else { ext };
|
||||
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
|
||||
Some(f) => format_extension(f),
|
||||
None => default_ext.to_string(),
|
||||
};
|
||||
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
|
||||
params.insert("path".into(), json!(file.to_string_lossy()));
|
||||
if let Some(o) = c.as_object_mut() {
|
||||
@@ -283,6 +287,21 @@ fn rewrite_paths(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The extension of the file a step writes in `format` (an id or extension, as [`super::format`]
|
||||
/// reads it: any case, a leading dot allowed): the extension as written, in lower case and without
|
||||
/// the dot, when it is one of the format's (`SVG` → `svg`, `.JPEG` → `jpeg`), else the format's
|
||||
/// first (`png8` → `png`, `template` → `vctemplate`). An unknown format (`xyz`) keeps its spelling,
|
||||
/// and the step fails with its command's error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn format_extension(format: &str) -> String {
|
||||
let written = format.trim_start_matches('.').to_ascii_lowercase();
|
||||
match super::format(format) {
|
||||
Some(f) if f.extensions.contains(&written.as_str()) => written,
|
||||
Some(f) => f.extensions.first().map_or(written, |e| (*e).to_string()),
|
||||
None => format.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Readable files under `folder` (and optionally its subfolders), capped.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
|
||||
@@ -410,6 +429,70 @@ mod tests {
|
||||
assert!(dest.join("only.svg").is_file());
|
||||
}
|
||||
|
||||
/// Override Export names each file with the extension of the format the step names, however
|
||||
/// the step spells it; a format given by one of its extensions keeps that extension.
|
||||
#[test]
|
||||
fn batch_override_export_names_files_with_the_format_s_extension() {
|
||||
let src = tmp("ext-src");
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
let cases = [(".svg", "a.svg"), ("SVG", "a.svg"), ("svg", "a.svg"), ("png8", "a.png"), ("template", "a.vctemplate"), (".JPEG", "a.jpeg")];
|
||||
for (i, (format, want)) in cases.into_iter().enumerate() {
|
||||
let dest = tmp(&format!("ext-out-{i}"));
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.export", "params": {"format": format}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{format}: {r}");
|
||||
assert_eq!(file_names(&dest), [want], "{format}: {r}");
|
||||
}
|
||||
// An unknown format keeps its spelling, and the step fails with the export's error.
|
||||
let dest = tmp("ext-out-unknown");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.export", "params": {"format": "xyz"}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 1, "{r}");
|
||||
assert!(r["processed"][0]["error"].as_str().is_some_and(|e| e.contains("unknown format `xyz`")), "{r}");
|
||||
assert!(file_names(&dest).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
/// Override Save names each file with the extension of the format the step names, also over
|
||||
/// data sets.
|
||||
#[test]
|
||||
fn batch_override_save_names_files_with_the_format_s_extension() {
|
||||
for (i, format) in [".svg", "SVG", ".PDF"].into_iter().enumerate() {
|
||||
let dest = tmp(&format!("ext-save-{i}"));
|
||||
let mut s = with_data_sets(&["a"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "file.saveAs", "params": {"format": format}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{format}: {r}");
|
||||
let want = format!("Untitled_a.{}", format.trim_start_matches('.').to_ascii_lowercase());
|
||||
assert_eq!(file_names(&dest), [want], "{format}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_datasets_applies_each_row() {
|
||||
let dest = tmp("sets");
|
||||
@@ -516,6 +599,32 @@ mod tests {
|
||||
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
|
||||
}
|
||||
|
||||
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name:
|
||||
/// Batch writes them as `-` in a data set's name, on every platform.
|
||||
#[test]
|
||||
fn batch_saves_data_sets_named_with_characters_windows_does_not_allow() {
|
||||
let dest = tmp("set-names-win");
|
||||
let mut s = with_data_sets(&["a?b", "c*d<e>", "f\"g|h", "i\u{7}j"]);
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"source": "datasets",
|
||||
"destination": "folder",
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"stopOnError": false,
|
||||
"commands": [{"command": "object.unlockAll", "params": {}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(
|
||||
file_names(&dest),
|
||||
["Untitled_a-b.vectorcraft", "Untitled_c-d-e-.vectorcraft", "Untitled_f-g-h.vectorcraft", "Untitled_i-j.vectorcraft"],
|
||||
"{r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_gives_each_data_set_a_file_of_its_own() {
|
||||
let dest = tmp("set-names-same");
|
||||
|
||||
@@ -99,7 +99,7 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
|
||||
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
|
||||
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
|
||||
/// written.
|
||||
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
|
||||
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
let open = std::mem::take(&mut s.docs);
|
||||
s.docs = start;
|
||||
@@ -107,5 +107,8 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.saturating_add(1);
|
||||
if s.menu.guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,7 +385,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
|
||||
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
|
||||
/// features approximated or left out; every format that draws type: fonts written in the fallback
|
||||
/// font).
|
||||
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).
|
||||
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
encode_all(doc, f.id, p)?.single(f)
|
||||
@@ -417,8 +417,10 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
// 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());
|
||||
if f.raster
|
||||
&& let Some(w) = crate::cmd::rasterfx::undecoded_warning(failed)
|
||||
{
|
||||
enc.warnings.push(w.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).
|
||||
@@ -527,6 +529,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
if format == RasterFormat::WebP && o.lossless == Some(false) {
|
||||
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if pages.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect();
|
||||
let transparent = doc.layers.iter().any(|l| l.shows_transparency());
|
||||
let flat = flatten_document(doc, &set.flatten)?;
|
||||
let drawn = flat.as_ref().unwrap_or(doc);
|
||||
@@ -302,6 +303,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
}
|
||||
}
|
||||
}
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ use crate::DocState;
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
|
||||
write_or_return,
|
||||
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
|
||||
};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -224,7 +223,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
|
||||
/// `transparent`.
|
||||
/// `transparent`, with the encoder's warnings.
|
||||
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.exportForOffice";
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
@@ -242,8 +241,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
|
||||
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
|
||||
let bytes = encode(doc, "png", &opts)?;
|
||||
let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?;
|
||||
// The pixel size, from the PNG header.
|
||||
let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes);
|
||||
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) }))
|
||||
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20), "warnings": warnings }))
|
||||
}
|
||||
@@ -76,9 +76,10 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
return format("svgz");
|
||||
}
|
||||
if vectorcraft_pdf::is_postscript(bytes) {
|
||||
// PostScript (.eps, and .ai saved without PDF compatibility) by any name; .ai and .ait
|
||||
// keep their meaning (a template), read as PostScript all the same.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("eps"));
|
||||
// PostScript (.eps, .ps, and .ai saved without PDF compatibility) by any name; .ai, .ait
|
||||
// and .ps keep their meaning (a template, a PostScript file), read as PostScript all the
|
||||
// same.
|
||||
return by_name.filter(|f| matches!(f.id, "ai" | "ait" | "ps")).or_else(|| format("eps"));
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
|
||||
@@ -167,6 +168,12 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
|
||||
if !read {
|
||||
return (doc, warnings);
|
||||
}
|
||||
// The layers' text objects hold type and the stroked paths around it, which can include stroked
|
||||
// glyphs the PDF part kept as outlines: without any such glyph in the document, the warnings on
|
||||
// outlines are dropped.
|
||||
if !vectorcraft_pdf::holds_outlines(&doc) {
|
||||
warnings.retain(|w| !vectorcraft_pdf::is_outlines_note(w));
|
||||
}
|
||||
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
|
||||
// to the page.
|
||||
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
|
||||
@@ -197,8 +204,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
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) => {
|
||||
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
|
||||
@@ -263,9 +270,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
{
|
||||
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
|
||||
}
|
||||
// Threaded type read from a file flows through its frames.
|
||||
// Threaded type read from a file flows through its frames (the threaded type of a restored
|
||||
// document keeps its text as saved), and every frame of a thread takes its first frame's
|
||||
// Optical Margin Alignment setting.
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
} else if !doc.text_threads.is_empty() {
|
||||
crate::cmd::threads::share_optical_margin(&mut doc);
|
||||
}
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
@@ -441,10 +452,10 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
|
||||
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
|
||||
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
|
||||
/// 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.
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode,
|
||||
/// counted as RGBA (4 bytes a pixel) or as the decoder gives them when that is more (its header
|
||||
/// says so; the pixels aren't read): Open, Place and the Links panel say it (#1479). `None` for
|
||||
/// images within the allowance, and for bytes that aren't an image.
|
||||
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
use image::ImageDecoder as _;
|
||||
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
|
||||
@@ -452,7 +463,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
}
|
||||
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"))
|
||||
// The allowance also counts the pixels as RGBA: an image of fewer than 4 bytes a pixel (gray,
|
||||
// gray with alpha, RGB) takes more than the decoder counts (#1717).
|
||||
let rgba_over = u64::from(w) * u64::from(h) > MAX_RASTER_ALLOC / 4;
|
||||
(rgba_over || 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
|
||||
@@ -460,6 +474,8 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
|
||||
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
|
||||
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
|
||||
/// 1-bit TIFFs compressed with CCITT Group 3, which the decoder can't read either, become RGBA
|
||||
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
|
||||
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
|
||||
/// either, become RGBA too, whether they store their channels together or one plane per channel.
|
||||
/// A TIFF with more strips or tiles than the decoder can count
|
||||
@@ -494,7 +510,10 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
};
|
||||
let img = match (f.id == "tiff").then(extra_samples).flatten() {
|
||||
Some(img) => img,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
|
||||
Some(read) => read.map_err(err)?,
|
||||
None => reader.decode().map_err(err)?.to_rgba8(),
|
||||
},
|
||||
};
|
||||
as_png(img, ppi)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kin
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
|
||||
//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip`
|
||||
//! packs its files as one download.
|
||||
//! packs its files as one download, and reads the files of Word documents placed as type.
|
||||
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
|
||||
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
|
||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
||||
@@ -45,7 +45,7 @@ mod save;
|
||||
mod screens;
|
||||
mod svg;
|
||||
mod text;
|
||||
mod zip;
|
||||
pub mod zip;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -78,7 +78,8 @@ pub use screens::{
|
||||
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
|
||||
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
|
||||
store_settings as store_screen_settings,
|
||||
};
|
||||
pub use svg::options_map as svg_options;
|
||||
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
|
||||
@@ -141,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
@@ -186,7 +187,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch…",
|
||||
[],
|
||||
None,
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
|
||||
always,
|
||||
action_batch::action_batch
|
||||
),
|
||||
@@ -213,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save for Office Documents…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}",
|
||||
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height, warnings} (pixels); no path → {dataBase64, bytes, width, height, warnings}; warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
|
||||
has_doc,
|
||||
export::export_for_office
|
||||
),
|
||||
@@ -635,7 +636,7 @@ pub const FORMATS: &[Format] = &[
|
||||
Format {
|
||||
id: "eps",
|
||||
label: "EPS",
|
||||
extensions: &["eps"],
|
||||
extensions: &["eps", "epsf"],
|
||||
mime: "application/postscript",
|
||||
read: true,
|
||||
write: true,
|
||||
@@ -651,6 +652,8 @@ pub const FORMATS: &[Format] = &[
|
||||
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
|
||||
// Photoshop's large document format: read like a PSD (its merged image), never written.
|
||||
reader("psb", "PSB", &["psb"], "image/x-psb", true),
|
||||
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
|
||||
reader("ps", "PostScript", &["ps"], "application/postscript", false),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -677,6 +680,8 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
@@ -684,8 +689,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
|
||||
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
|
||||
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
|
||||
/// them (File › Open and Place still read them).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb"];
|
||||
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
|
||||
/// them all).
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
@@ -702,7 +708,14 @@ pub fn export_extensions() -> Vec<&'static str> {
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
/// RTF files: File → Place sets them as area type with their formatting.
|
||||
pub const RTF_EXTS: &[&str] = &["rtf"];
|
||||
|
||||
/// Word documents: File → Place sets them as area type with their formatting.
|
||||
pub const DOCX_EXTS: &[&str] = &["docx"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and
|
||||
/// [`DOCX_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
@@ -726,10 +739,14 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"emf",
|
||||
"wmf",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
"af",
|
||||
"afdesign",
|
||||
"afpub",
|
||||
"txt",
|
||||
"rtf",
|
||||
"docx",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
@@ -753,11 +770,14 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
|
||||
/// RTF and Word.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All placeable files", PLACE_EXTS))
|
||||
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
|
||||
.chain(std::iter::once(("Text", TEXT_EXTS)))
|
||||
.chain(std::iter::once(("RTF", RTF_EXTS)))
|
||||
.chain(std::iter::once(("Word", DOCX_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
|
||||
//!
|
||||
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
|
||||
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
|
||||
//! The characters Windows does not allow in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`,
|
||||
//! `*` and control characters) become `-`, in the template's text and in what the tokens put in.
|
||||
|
||||
use super::screens::file_part;
|
||||
|
||||
/// What a template's tokens stand for, for one file.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -50,11 +53,6 @@ fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
|
||||
fn clean(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
|
||||
pub fn has_suffix_token(template: &str) -> bool {
|
||||
template.contains("<suffix>")
|
||||
@@ -72,12 +70,12 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
|
||||
let mut out = String::with_capacity(t.len() + 16);
|
||||
let mut rest = t;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
// Bracketed tokens; an unknown one stays as written.
|
||||
// Bracketed tokens; an unknown one stays as written, with `-` for its brackets.
|
||||
if c == '<'
|
||||
&& let Some(end) = rest.find('>')
|
||||
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
|
||||
{
|
||||
out.push_str(&clean(&value));
|
||||
out.push_str(&value);
|
||||
rest = rest.get(end + 1..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
@@ -94,10 +92,10 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
|
||||
rest = rest.get(pat.len()..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
|
||||
out.push(c);
|
||||
rest = rest.get(c.len_utf8()..).unwrap_or("");
|
||||
}
|
||||
out.trim().to_string()
|
||||
file_part(out.trim())
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
|
||||
@@ -133,7 +131,7 @@ mod tests {
|
||||
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
|
||||
// Words are left alone: only the date patterns and bracketed tokens are replaced.
|
||||
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
|
||||
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
|
||||
assert_eq!(render("<unknown>-x", &p), "-unknown--x");
|
||||
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
|
||||
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
|
||||
}
|
||||
@@ -143,6 +141,9 @@ mod tests {
|
||||
let p = NameParts { name: "../../etc", ..parts() };
|
||||
let n = render("a/b\\c:<name>", &p);
|
||||
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
|
||||
// Nor any other character Windows does not allow in a file name, typed or put in.
|
||||
let p = NameParts { name: "<a>\"b|c?d*\u{0}\u{1f}", ..parts() };
|
||||
assert_eq!(render("<name>|?*\u{1}", &p), "-a--b-c-d-------");
|
||||
// A long counter is padded, a short run is not cut.
|
||||
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
|
||||
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
|
||||
|
||||
@@ -254,9 +254,21 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
|
||||
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?;
|
||||
warnings.extend(drawn_warnings);
|
||||
warnings.extend(overflow_warning(cmd, &full, p)?);
|
||||
Ok((bytes, warnings))
|
||||
}
|
||||
|
||||
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
|
||||
/// whose text doesn't fit (see [`crate::cmd::fonts::export_overflow_warning`]): on the pages
|
||||
/// chosen, leaving out the layers that don't print unless the PDF writes them.
|
||||
fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<String>> {
|
||||
let opts = options(cmd, doc, p)?;
|
||||
let areas = vectorcraft_pdf::page_areas(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
let set = &opts.settings;
|
||||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||||
Ok(crate::cmd::fonts::export_overflow_warning(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas)))
|
||||
}
|
||||
|
||||
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
|
||||
/// native document with its save options, whose pages may be left blank).
|
||||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
|
||||
@@ -296,17 +296,18 @@ fn is_svg(f: &Format) -> bool {
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at and the Layers panel's open
|
||||
/// rows.
|
||||
/// rows and transparency grid visibility.
|
||||
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
|
||||
if !is_native(f) {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let open = st.layers_open.saved(&st.doc);
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open {
|
||||
if st.doc.last_view == st.view && st.doc.layers_open == open && st.doc.transparency_grid == st.transparency_grid {
|
||||
return st.doc.clone();
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
d.transparency_grid = st.transparency_grid;
|
||||
d.layers_open = open;
|
||||
Arc::new(d)
|
||||
}
|
||||
@@ -647,7 +648,8 @@ impl SaveJob {
|
||||
|
||||
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
|
||||
/// title, format, options, and the snapshot as the saved state (edits made since the job was
|
||||
/// made keep the document modified).
|
||||
/// made keep the document modified; changes that are not edits to save don't,
|
||||
/// [`DocState::same_content`]).
|
||||
pub fn finish(self, st: &mut DocState) {
|
||||
let retargets = self.plan.retargets();
|
||||
if let (true, Some(path)) = (retargets, self.plan.path) {
|
||||
@@ -659,6 +661,12 @@ impl SaveJob {
|
||||
if let Some(at) = self.stamped {
|
||||
if Arc::ptr_eq(&st.doc, &self.source) {
|
||||
st.doc = self.snapshot.clone();
|
||||
} else if st.same_content(&self.source) {
|
||||
// Changed while the save ran only by changes that are not edits to save
|
||||
// (Puppet Warp pins dropped, Edit Text ended, type laid out again after a font
|
||||
// scan): with the dates, the document counts as saved.
|
||||
stamp_save_dates(st, at);
|
||||
return st.mark_saved();
|
||||
} else {
|
||||
// Edited while the save ran: the dates still go on, the edits stay unsaved. The
|
||||
// undo history holds the document as it was before the dates, so that is the
|
||||
|
||||
@@ -21,7 +21,8 @@ use super::encode::single_artboard;
|
||||
use super::export::{isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names,
|
||||
with_black_output, writable, write_file,
|
||||
};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
@@ -148,10 +149,29 @@ fn safe_name(s: &str) -> Option<String> {
|
||||
(!n.trim_matches('.').is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
|
||||
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
|
||||
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch, a name pattern's text
|
||||
/// and the names it puts in): the characters Windows does not allow in a file name (`/`, `\`, `:`,
|
||||
/// `<`, `>`, `"`, `|`, `?`, `*` and the control characters U+0000 to U+001F,
|
||||
/// <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>) become `-` on every
|
||||
/// platform, so the name cannot make a subfolder or point outside the folder the file is written to.
|
||||
pub(super) fn file_part(s: &str) -> String {
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
|
||||
s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') || c < ' ' { '-' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// A whole file or folder name (`[email protected]`, a sub-folder) through [`file_part`], without
|
||||
/// trailing periods and spaces, and with `-` after a Windows device name that is the whole name or
|
||||
/// comes before its first period: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` and `LPT1` to `LPT9`,
|
||||
/// also with `¹`, `²` or `³` for the digit, in any case (`CON.png` becomes `CON-.png`). The same on
|
||||
/// every platform.
|
||||
pub(crate) fn safe_file_name(s: &str) -> String {
|
||||
let name = file_part(s);
|
||||
let name = name.trim_end_matches(['.', ' ']);
|
||||
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
|
||||
let upper = base.to_ascii_uppercase();
|
||||
let numbered = |n: &str| n.chars().count() == 1 && n.chars().all(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'));
|
||||
let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| upper.strip_prefix("COM").or_else(|| upper.strip_prefix("LPT")).is_some_and(numbered);
|
||||
if device { format!("{base}-{rest}") } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// Keys of a format row that aren't encoder options.
|
||||
@@ -541,7 +561,8 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
|
||||
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files the `name` pattern gives one name.
|
||||
/// so do different files the `name` pattern gives one name. The reply's `warnings` lists every
|
||||
/// warning the encoders give for the files, each once.
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = ScreenParams::of(p)?;
|
||||
let rows = rows_of(s, p, o.preset.as_deref())?;
|
||||
@@ -588,8 +609,18 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
|
||||
let mut out = Output::default();
|
||||
// The encoders' warnings for every file, each once.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
// Appearance of Black › Printing / Exporting for RGB raster rows.
|
||||
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
|
||||
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
|
||||
let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?;
|
||||
for w in notes {
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
};
|
||||
// A whole-document copy of an artboard, for the formats that write documents to edit.
|
||||
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
|
||||
let mut files = 0u64;
|
||||
@@ -615,10 +646,10 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = format!("{stem}.{ext}");
|
||||
let file = safe_file_name(&format!("{stem}.{ext}"));
|
||||
let sub = match folders {
|
||||
Folders::None => None,
|
||||
Folders::Rows => Some(r.folder.clone()),
|
||||
Folders::Rows => Some(safe_file_name(&r.folder)),
|
||||
Folders::Format => Some(ext.to_string()),
|
||||
Folders::Item => Some(item.stem.clone()),
|
||||
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
|
||||
@@ -644,20 +675,24 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
|
||||
let zip_name = format!("{prefix}{filename}.zip");
|
||||
let zip_name = safe_file_name(&format!("{prefix}{filename}.zip"));
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
write_file(&path, &zip)?;
|
||||
Ok(json!({ "path": path, "bytes": zip.len(), "files": names }))
|
||||
Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings }))
|
||||
}
|
||||
None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })),
|
||||
None => Ok(
|
||||
json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names, "warnings": warnings }),
|
||||
),
|
||||
};
|
||||
}
|
||||
Ok(match folder {
|
||||
Some(_) => json!({ "files": out.written }),
|
||||
Some(_) => json!({ "files": out.written, "warnings": warnings }),
|
||||
None => {
|
||||
json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
|
||||
let files: Vec<Value> =
|
||||
out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
json!({ "files": files, "warnings": warnings })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -670,8 +705,10 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
|
||||
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
|
||||
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
|
||||
};
|
||||
// Each item's stem follows Windows' file-naming rules, and no two items share one.
|
||||
let safe = |stems: Vec<String>| distinct_names(stems.iter().map(|s| safe_file_name(s)));
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
|
||||
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
@@ -688,7 +725,8 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
|
||||
(doc.clone(), Rect::ZERO)
|
||||
};
|
||||
let name = filename.to_string();
|
||||
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
|
||||
let stem = safe_file_name(&name);
|
||||
return Ok(vec![Item { name, stem, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
|
||||
}
|
||||
if let Some(ids) = &o.assets {
|
||||
let mut pieces = vec![];
|
||||
@@ -742,7 +780,7 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = artboard_file_names(doc, &boards);
|
||||
let stems = safe(artboard_file_names(doc, &boards));
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
|
||||
@@ -157,6 +157,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
// that preparation out of the native attachment so missing links remain relinkable.
|
||||
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let doc = &*full;
|
||||
// A chosen artboard's file holds the art over it; otherwise the file holds all of it.
|
||||
let regions: Option<Vec<vectorcraft_geom::Rect>> =
|
||||
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
|
||||
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
@@ -184,5 +188,6 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
{
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
enc.warnings.extend(overflow);
|
||||
Ok(enc)
|
||||
}
|
||||
@@ -208,6 +208,113 @@ fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
|
||||
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
|
||||
}
|
||||
|
||||
/// A 70 × 7 picture, `true` for black: a white row and a black row (runs past 63 take a make-up
|
||||
/// code), then runs of several lengths.
|
||||
fn fax_picture() -> Vec<Vec<bool>> {
|
||||
(0..7).map(|y| (0..70).map(|x| if y < 2 { y == 1 } else { (x * (y + 2) / 5 + y) % 3 == 0 }).collect()).collect()
|
||||
}
|
||||
|
||||
/// The first `rows` rows of [`fax_picture`] as a TIFF compressed with CCITT Group 3 in one strip:
|
||||
/// `compression` 2 or 3 with `t4_options`, white is zero, the highest bit of each byte first.
|
||||
fn group3_tiff(compression: u16, t4_options: u32, rows: usize) -> Vec<u8> {
|
||||
let rows = &fax_picture()[..rows];
|
||||
let g =
|
||||
vectorcraft_testkit::tiff::Group3 { rows, compression, t4_options, photometric: 0, fill_order: 1, rows_per_strip: 7, tile: None, rtc: false };
|
||||
vectorcraft_testkit::tiff::group3(&g)
|
||||
}
|
||||
|
||||
/// 1-bit TIFFs compressed with CCITT Group 3 open and place with their pixels (#1466): Compression
|
||||
/// 2 (modified Huffman) and Compression 3 (ITU-T T.4) with one-dimensional rows or two-dimensional
|
||||
/// ones (T4Options bit 0), with and without fill bits (bit 2) and six EOLs at the end, in one strip,
|
||||
/// three strips or three 32 × 16 tiles, each byte's highest or lowest bit first (FillOrder 1 and 2),
|
||||
/// white or black as zero.
|
||||
#[test]
|
||||
fn ccitt_group3_tiffs_open_and_place_with_their_pixels() {
|
||||
let rows = fax_picture();
|
||||
let mut s = Session::new();
|
||||
for (compression, t4_options, rtc) in [(2, 0, false), (3, 0, false), (3, 4, true), (3, 1, false), (3, 5, true)] {
|
||||
for (rows_per_strip, tile) in [(7, None), (3, None), (7, Some((32, 16)))] {
|
||||
for fill_order in [1, 2] {
|
||||
for photometric in [0, 1] {
|
||||
let what = format!(
|
||||
"Compression {compression}, T4Options {t4_options}, {rows_per_strip} rows a strip, tiles {tile:?}, FillOrder {fill_order}, photometric {photometric}"
|
||||
);
|
||||
let g = vectorcraft_testkit::tiff::Group3 {
|
||||
rows: &rows,
|
||||
compression,
|
||||
t4_options,
|
||||
photometric,
|
||||
fill_order,
|
||||
rows_per_strip,
|
||||
tile,
|
||||
rtc,
|
||||
};
|
||||
let file = vectorcraft_testkit::tiff::group3(&g);
|
||||
assert!(image::load_from_memory(&file).is_err(), "{what}: the image decoder reads Group 3 now");
|
||||
// A black run's pixels are 1 bits, which show as black when white is zero.
|
||||
let want: Vec<u8> = rows.concat().iter().map(|&black| if black == (photometric == 0) { 0 } else { 255 }).collect();
|
||||
let pixels = |s: &Session| {
|
||||
let blob = &s.doc().unwrap().doc.images[&image_of(s).key];
|
||||
image::load_from_memory(&blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_luma8().into_raw()
|
||||
};
|
||||
open(&mut s, "fax.tif", &file);
|
||||
assert_eq!(pixels(&s), want, "{what}: Open");
|
||||
let mut placed = session(100.0, 100.0, 1);
|
||||
placed.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
|
||||
assert_eq!(pixels(&placed), want, "{what}: Place");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Group 3 TIFF whose pixels would take more than the decoding allowance as RGBA is refused as
|
||||
/// too large to draw, without a panic at 2^31 × 2^31 pixels too. One whose data ends inside a row,
|
||||
/// or after a whole row before the strip's last, is refused as damaged, and so is one whose tiles
|
||||
/// are wider than the allowance.
|
||||
#[test]
|
||||
fn ccitt_group3_tiffs_too_large_or_cut_short_are_refused() {
|
||||
// ImageWidth (the directory's first entry, its value at offset 18), ImageLength (the second, at
|
||||
// 30) and RowsPerStrip (the eighth, at 102).
|
||||
for (width, height) in [(70u32, 1u32 << 30), (1 << 31, 1 << 31)] {
|
||||
let mut big = group3_tiff(3, 1, 7);
|
||||
big[18..22].copy_from_slice(&width.to_le_bytes());
|
||||
big[30..34].copy_from_slice(&height.to_le_bytes());
|
||||
big[102..106].copy_from_slice(&height.to_le_bytes());
|
||||
let e = raster_image(&big).err().expect("refused");
|
||||
assert!(e.to_string().contains(&format!("an image of {width} × {height} pixels is too large to draw")), "{e}");
|
||||
}
|
||||
let mut short = group3_tiff(3, 0, 7);
|
||||
short.truncate(short.len() - 6);
|
||||
let e = raster_image(&short).err().expect("refused");
|
||||
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
|
||||
// Compression 2 rows start at a byte, so the strip of the first six rows is where the strip of
|
||||
// seven starts. StripByteCounts (the ninth entry, its value at offset 114) gives the six rows'.
|
||||
let (mut seven, six) = (group3_tiff(2, 0, 7), group3_tiff(2, 0, 6));
|
||||
seven[114..118].copy_from_slice(&six[114..118]);
|
||||
assert_eq!(seven[30..34], 7u32.to_le_bytes(), "ImageLength");
|
||||
assert_eq!(seven[78..82], six[78..82], "StripOffsets (the sixth entry, at offset 78)");
|
||||
let e = raster_image(&seven).err().expect("refused");
|
||||
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
|
||||
// A tile wider than the allowance: TileWidth (the eighth entry, its value at offset 102).
|
||||
let rows = fax_picture();
|
||||
let g = vectorcraft_testkit::tiff::Group3 {
|
||||
rows: &rows,
|
||||
compression: 3,
|
||||
t4_options: 1,
|
||||
photometric: 0,
|
||||
fill_order: 1,
|
||||
rows_per_strip: 7,
|
||||
tile: Some((80, 16)),
|
||||
rtc: false,
|
||||
};
|
||||
let mut wide = vectorcraft_testkit::tiff::group3(&g);
|
||||
assert_eq!(wide[102..106], 80u32.to_le_bytes(), "TileWidth");
|
||||
wide[102..106].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let e = raster_image(&wide).err().expect("refused");
|
||||
assert!(e.to_string().contains("could not decode image: arithmetic overflow"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_beats_a_wrong_extension() {
|
||||
let png = image_bytes(2, 2, image::ImageFormat::Png);
|
||||
|
||||
@@ -303,6 +303,144 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
|
||||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
}
|
||||
|
||||
/// `data` as ASCII85, up to the `~>`.
|
||||
fn ascii85(data: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
for chunk in data.chunks(4) {
|
||||
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
|
||||
let mut d = [0u8; 5];
|
||||
for c in d.iter_mut().rev() {
|
||||
*c = (x % 85) as u8 + b'!';
|
||||
x /= 85;
|
||||
}
|
||||
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
|
||||
}
|
||||
out.push_str("~>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Page content drawing one glyph and then `rest` in 10 pt on the baseline at `y`, from x = 14, in
|
||||
/// rendering mode `mode` (0 fills the type, 1 strokes it). The glyph is code 0x41 of the font `F2`,
|
||||
/// standing for the "fi" of the story, and `F2` isn't installed and its `ToUnicode` map gives that
|
||||
/// glyph U+FFFD, so that the PDF part keeps it as outlines; `rest` is in Helvetica (`F1`).
|
||||
fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String {
|
||||
format!("{mode} Tr BT /F2 10 Tf 14 {y} Td (A) Tj ET BT /F1 10 Tf 19 {y} Td ({rest}) Tj ET ")
|
||||
}
|
||||
|
||||
/// A `.ai` whose page draws a gray square and `type_content` (see [`half_outlined_line`]), and whose
|
||||
/// layer `Art` has the square and text objects: the first of story 0, which `story` gives in the
|
||||
/// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't
|
||||
/// have. Without `story` the file has no text document.
|
||||
fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec<u8> {
|
||||
let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n");
|
||||
let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect();
|
||||
let mut body =
|
||||
format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
|
||||
if let Some(story) = story {
|
||||
// The story's one line starts at the canvas center (8191.5, the template box's center) moved
|
||||
// to (14, 65): its y runs down the page.
|
||||
let n = story.chars().count();
|
||||
let document = format!(
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
|
||||
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 10.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> >> >> >> /1 {} >> ] >> >> \
|
||||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8105.5 8176.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
|
||||
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 {} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n",
|
||||
n + 1,
|
||||
n + 1,
|
||||
n + 1
|
||||
);
|
||||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
body.push_str(&format!("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", lines.join("\n")));
|
||||
}
|
||||
let data = ai::editing_data(200.0, 100.0, &body);
|
||||
let content = format!("0.5 g 120 20 40 40 re f 0.2 g 0.2 G {type_content}");
|
||||
let page = PdfPage {
|
||||
resources: format!(
|
||||
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /VectorCraftTestUnnamed /ToUnicode {} 0 R >> >>",
|
||||
first_extra(1) + 1
|
||||
),
|
||||
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
|
||||
..PdfPage::new(200.0, 100.0, &content)
|
||||
};
|
||||
let cmap = "begincmap\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n1 beginbfchar\n<41> <FFFD>\nendbfchar\nendcmap\n";
|
||||
pdf_with(
|
||||
&[page],
|
||||
&[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn outlines_notes(r: &Value) -> Vec<String> {
|
||||
let notes = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str);
|
||||
notes.filter(|w| vectorcraft_pdf::is_outlines_note(w)).map(String::from).collect()
|
||||
}
|
||||
|
||||
/// The notes the PDF part of [`half_outlined_ai`] reads with: its glyph of U+FFFD, kept as outlines.
|
||||
fn pdf_part_outlines_notes() -> [String; 2] {
|
||||
[format!("text in Vector Craft Test Unnamed {}", vectorcraft_pdf::UNNAMED_CHARACTERS_NOTE), vectorcraft_pdf::OUTLINED_TEXT_NOTE.into()]
|
||||
}
|
||||
|
||||
/// The note on text objects that hold the page's type without some of its characters, for `n` of
|
||||
/// them.
|
||||
fn without_outlines_note(n: usize) -> Value {
|
||||
json!(format!("{n} text objects hold their type as the page draws it, without the characters the page has only as outlines"))
|
||||
}
|
||||
|
||||
/// The texts in the first layer.
|
||||
fn texts(s: &Session) -> Vec<String> {
|
||||
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
|
||||
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
|
||||
/// text document, and stays with the PDF part alone.
|
||||
#[test]
|
||||
fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() {
|
||||
let file = half_outlined_ai(Some("first draft"), 0, &half_outlined_line(65.0, "rst draft", 0));
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "draft.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(texts(&s), ["first draft"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
let r = open(&mut s, "draft.ai", &file, json!({"editingData": false})).unwrap();
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
// A second text object that takes the page's type, away from the glyph: no note on it.
|
||||
let page = half_outlined_line(65.0, "rst draft", 0) + "BT /F1 10 Tf 14 30 Td (notes) Tj ET";
|
||||
let r = open(&mut s, "notes.ai", &half_outlined_ai(Some("first draft"), 1, &page), json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["first draft", "notes"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
}
|
||||
|
||||
/// A text object that takes the page's type lacks the glyphs next to it that the page kept as
|
||||
/// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that
|
||||
/// come in with the stroked type keep the PDF part's notes.
|
||||
#[test]
|
||||
fn type_from_the_page_opens_with_a_note_on_the_characters_it_has_only_as_outlines() {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 0)), json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(texts(&s), ["rst draft"], "{r}");
|
||||
assert!(!vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
|
||||
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
// Stroked: the glyph comes in with the type.
|
||||
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 1)), json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert!(vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
assert!(!r["warnings"].to_string().contains("without the characters"), "{r}");
|
||||
// A stroked line whose glyph comes in, and a filled one whose glyph doesn't.
|
||||
let page = half_outlined_line(65.0, "rst draft", 1) + &half_outlined_line(30.0, "nd more", 0);
|
||||
let r = open(&mut s, "both.ai", &half_outlined_ai(None, 1, &page), json!({})).unwrap();
|
||||
assert_eq!(texts(&s), ["rst draft", "nd more"], "{r}");
|
||||
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
|
||||
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
|
||||
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
|
||||
|
||||
@@ -105,6 +105,17 @@ fn the_counter_counts_items_or_files_from_any_start() {
|
||||
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
|
||||
}
|
||||
|
||||
/// Export for Screens writes the characters Windows does not allow in a file name (`<`, `>`, `"`,
|
||||
/// `|`, `?`, `*`, control characters) as `-` in a pattern's text and in the names its tokens stand
|
||||
/// for, on every platform.
|
||||
#[test]
|
||||
fn a_pattern_writes_the_characters_windows_does_not_allow_as_dashes() {
|
||||
let mut s = session(1);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": "a|b?\"c\u{7}"})).unwrap();
|
||||
let r = export(&mut s, json!({"name": "<name>*<x>", "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["a-b--c---x-.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_and_the_selection_are_exported_by_name() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -172,3 +172,44 @@ fn eps_files_place_as_one_group_clipped_to_their_bounding_box() {
|
||||
assert!(format("eps").is_some_and(|f| f.read && f.write));
|
||||
assert!(OPEN_EXTS.contains(&"eps") && PLACE_EXTS.contains(&"eps"));
|
||||
}
|
||||
|
||||
/// PostScript `.ps` files open and place as EPS files do, under a PostScript format that exports
|
||||
/// don't write, and `.epsf` files are EPS files; the open and place dialogs list both.
|
||||
#[test]
|
||||
fn ps_and_epsf_files_open_and_place() {
|
||||
use crate::cmd::clipboard::{EPS, PASTE_ORDER, file_flavour};
|
||||
// Pages without a bounding box (each artboard is a letter page), one of them a program whose
|
||||
// header is only `%!`.
|
||||
let page = b"%!PS-Adobe-3.0\n%%Pages: 1\n%%EndComments\n%%Page: 1 1\n1 0 0 setrgbcolor\n\
|
||||
newpath 72 72 moveto 144 0 rlineto 0 72 rlineto -144 0 rlineto closepath fill\nshowpage\n%%EOF\n";
|
||||
let bare = b"%!\n0 0 1 setrgbcolor\n100 100 50 50 rectfill\nshowpage\n";
|
||||
let mut s = Session::new();
|
||||
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("PAGE.PS", page, "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
|
||||
let r = open(&mut s, name, bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!((&r["format"], &r["warnings"]), (&json!(id), &json!([])), "{name}: {r}");
|
||||
assert_eq!(objects(&s), 1, "{name}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(612.0, 792.0), "{name}");
|
||||
}
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
for (name, bytes, id) in [("page.ps", &page[..], "ps"), ("bare.ps", bare, "ps"), ("bare.epsf", bare, "eps")] {
|
||||
let r = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!(id), Some(1)), "{name}: {r}");
|
||||
}
|
||||
// Copied in a file manager, they paste as EPS.
|
||||
for (name, bytes) in [("page.ps", &page[..]), ("bare.ps", bare), ("bare.epsf", bare), ("bare.eps", bare)] {
|
||||
assert_eq!(file_flavour(name, bytes, &PASTE_ORDER), Some(EPS), "{name}");
|
||||
let r = s
|
||||
.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(bytes)}))
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r["count"], json!(1), "{name}: {r}");
|
||||
}
|
||||
// The dialogs list them, and the desktop packaging doesn't associate them with the app.
|
||||
assert!(format("ps").is_some_and(|f| f.read && !f.write) && format("epsf").is_some_and(|f| f.id == "eps" && f.write));
|
||||
for e in ["ps", "epsf"] {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
}
|
||||
assert!(open_filters().any(|f| f == ("PostScript", &["ps"][..])) && place_filters().any(|f| f == ("PostScript", &["ps"][..])));
|
||||
// Exports write no .ps: Print to PostScript does.
|
||||
let e = writable_format(None, Some("out.ps")).unwrap_err();
|
||||
assert_eq!(e, "PostScript files can be opened but not written (see document.formats)");
|
||||
}
|
||||
@@ -110,6 +110,42 @@ fn density_buckets_go_into_sub_folders() {
|
||||
assert_eq!(names(&r), ["Artboard-2-..-x-..-y.svg", "Artboard-2-ok.png"]);
|
||||
}
|
||||
|
||||
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name
|
||||
/// (<https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>): Export for Screens
|
||||
/// writes them as `-` in a prefix and a suffix, on every platform.
|
||||
#[test]
|
||||
fn a_prefix_or_suffix_writes_the_characters_windows_does_not_allow_as_dashes() {
|
||||
let mut s = session(1);
|
||||
let r = export(&mut s, json!({"prefix": "a<b>c\"", "formats": [{"format": "svg", "suffix": "|d?e*f\u{1}\t"}]}));
|
||||
assert_eq!(names(&r), ["a-b-c-Artboard-1-d-e-f--.svg"]);
|
||||
}
|
||||
|
||||
/// A file or folder named after a Windows device (`CON`, `NUL`, `COM1`…, alone or before an
|
||||
/// extension) gets a `-` after the device name, and a folder loses a trailing period, on every
|
||||
/// platform.
|
||||
#[test]
|
||||
fn device_names_and_trailing_periods_follow_windows_naming_rules() {
|
||||
let mut s = session(3);
|
||||
for (index, name) in ["CON", "CON-", "lpt1"].iter().enumerate() {
|
||||
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
|
||||
}
|
||||
let r = export(&mut s, json!({"formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["CON-.svg", "CON--2.svg", "lpt1-.svg"]);
|
||||
let r =
|
||||
export(&mut s, json!({"range": "1", "subfolders": true, "formats": [{"format": "svg", "folder": "aux"}, {"format": "pdf", "folder": "x."}]}));
|
||||
assert_eq!(names(&r), ["aux-/CON-.svg", "x/CON-.pdf"]);
|
||||
let r = export(&mut s, json!({"range": "3", "folders": "item", "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["lpt1-/lpt1-.svg"]);
|
||||
// A name pattern, with an extension after the device name, and the document's own name.
|
||||
let r = export(&mut s, json!({"range": "1", "name": "<name>.tar", "formats": [{"format": "svg"}]}));
|
||||
assert_eq!(names(&r), ["CON-.tar.svg"]);
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
|
||||
let r = export(&mut s, json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}));
|
||||
assert_eq!(names(&r), ["nul-/nul-.pdf"]);
|
||||
let r = s.execute("document.exportForScreens", &json!({"zip": true, "formats": [{"format": "svg"}]})).unwrap();
|
||||
assert_eq!(r["name"], "nul-.zip");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sub_folders_are_made_in_the_export_folder() {
|
||||
@@ -164,6 +200,45 @@ fn a_zip_is_written_into_the_folder() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The warnings in `r` that contain `text`.
|
||||
fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> {
|
||||
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn every_reply_says_once_when_an_image_could_not_be_decoded() {
|
||||
let mut s = session(1);
|
||||
let rows = json!([{"format": "png"}, {"format": "jpg", "scale": "2x"}]);
|
||||
assert_eq!(export(&mut s, json!({ "formats": rows }))["warnings"], json!([]));
|
||||
// The renderer can't decode the placed image and draws nothing in its place.
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
let dir = std::env::temp_dir().join(format!("vc-screens-undecoded-{}", std::process::id()));
|
||||
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
|
||||
for p in [
|
||||
json!({ "formats": rows }),
|
||||
json!({ "formats": rows, "zip": true }),
|
||||
json!({ "formats": rows, "folder": folder }),
|
||||
json!({ "formats": rows, "folder": folder, "zip": true }),
|
||||
json!({ "formats": rows, "fullDocument": true }),
|
||||
] {
|
||||
let r = export(&mut s, p.clone());
|
||||
assert_eq!(warned(&r, "couldn't be decoded").len(), 1, "{p}: {}", r["warnings"]);
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "the files are still written");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_in_a_missing_family_is_named_once_for_every_format() {
|
||||
let mut s = session(2);
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let r = export(&mut s, json!({"formats": [{"format": "png"}, {"format": "pdf"}, {"format": "eps"}]}));
|
||||
let fallback = warned(&r, "fallback font");
|
||||
assert_eq!(fallback.len(), 1, "{}", r["warnings"]);
|
||||
assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_document_gives_one_file_per_format() {
|
||||
let mut s = session(3);
|
||||
|
||||
@@ -85,3 +85,19 @@ fn save_for_office_writes_a_png_at_the_resolution() {
|
||||
let spec = crate::cmd::find_command("document.exportForOffice").unwrap();
|
||||
assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_for_office_says_when_an_image_or_a_font_is_missing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
assert_eq!(s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap()["warnings"], json!([]));
|
||||
// The renderer can't decode the placed image, and the type's family isn't installed.
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let r = s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap();
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings.contains(&"an image couldn't be decoded and is missing from the picture"), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("fallback font") && w.contains("Nonexistent Sans Probe")), "{warnings:?}");
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)), "the PNG is still written");
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
|
||||
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
|
||||
//!
|
||||
//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT
|
||||
//! describes the format (<https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>).
|
||||
|
||||
use std::io::Read as _;
|
||||
|
||||
use flate2::Crc;
|
||||
|
||||
@@ -65,6 +70,145 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Does `bytes` start with a ZIP archive's first local file header?
|
||||
pub fn is_zip(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&0x0403_4b50u32.to_le_bytes())
|
||||
}
|
||||
|
||||
/// Why [`Archive`] can't read an archive or one of its files.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ReadError {
|
||||
/// Not a ZIP archive: no end of central directory record.
|
||||
NotZip,
|
||||
/// More files than the reader was asked to accept.
|
||||
TooManyFiles,
|
||||
/// The archive needs the 64-bit (ZIP64) records, which the reader does not read.
|
||||
Zip64,
|
||||
/// The file is encrypted.
|
||||
Encrypted(String),
|
||||
/// The file is compressed with a method other than stored (0) or deflated (8).
|
||||
Method(String, u16),
|
||||
/// The file unpacks to more bytes than the reader was asked to accept.
|
||||
TooLarge(String),
|
||||
/// The archive (empty name) or the file is truncated, inconsistent or fails its CRC.
|
||||
Damaged(String),
|
||||
}
|
||||
|
||||
/// One file of an archive, as its central directory record describes it.
|
||||
struct Entry {
|
||||
name: String,
|
||||
flags: u16,
|
||||
method: u16,
|
||||
crc: u32,
|
||||
packed: usize,
|
||||
size: usize,
|
||||
local: usize,
|
||||
}
|
||||
|
||||
/// A ZIP archive's files, listed from its central directory.
|
||||
pub struct Archive<'a> {
|
||||
zip: &'a [u8],
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
/// The archive `zip`, with at most `max_files` files.
|
||||
pub fn open(zip: &'a [u8], max_files: usize) -> Result<Self, ReadError> {
|
||||
let damaged = || ReadError::Damaged(String::new());
|
||||
let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
|
||||
let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
// The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature
|
||||
// whose comment ends the archive, else the last signature (the archive has bytes after it).
|
||||
let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?;
|
||||
let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50));
|
||||
let end =
|
||||
candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?;
|
||||
let count = u16_at(end + 10).ok_or_else(damaged)?;
|
||||
let start = u32_at(end + 16).ok_or_else(damaged)?;
|
||||
if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) {
|
||||
return Err(ReadError::Zip64);
|
||||
}
|
||||
let count = usize::from(count);
|
||||
if count > max_files {
|
||||
return Err(ReadError::TooManyFiles);
|
||||
}
|
||||
let mut at = start as usize;
|
||||
let mut entries = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
if u32_at(at) != Some(0x0201_4b50) {
|
||||
return Err(damaged());
|
||||
}
|
||||
let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged);
|
||||
let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged);
|
||||
let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?));
|
||||
let name_at = at.checked_add(46).ok_or_else(damaged)?;
|
||||
let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?;
|
||||
entries.push(Entry {
|
||||
name: String::from_utf8_lossy(name).into_owned(),
|
||||
flags: short(8)?,
|
||||
method: short(10)?,
|
||||
crc: long(16)?,
|
||||
packed: long(20)? as usize,
|
||||
size: long(24)? as usize,
|
||||
local: long(42)? as usize,
|
||||
});
|
||||
at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?;
|
||||
}
|
||||
Ok(Self { zip, entries })
|
||||
}
|
||||
|
||||
/// The file `name`, matched in any ASCII case.
|
||||
fn entry(&self, name: &str) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Does the archive hold the file `name` (in any ASCII case)?
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.entry(name).is_some()
|
||||
}
|
||||
|
||||
/// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such
|
||||
/// file), when it unpacks to at most `max` bytes and passes its CRC.
|
||||
pub fn read(&self, name: &str, max: usize) -> Result<Option<Vec<u8>>, ReadError> {
|
||||
let Some(e) = self.entry(name) else { return Ok(None) };
|
||||
let damaged = || ReadError::Damaged(e.name.clone());
|
||||
if e.flags & 1 != 0 {
|
||||
return Err(ReadError::Encrypted(e.name.clone()));
|
||||
}
|
||||
if e.size > max {
|
||||
return Err(ReadError::TooLarge(e.name.clone()));
|
||||
}
|
||||
// The local header: its signature, then its name and extra field lengths at 26 and 28.
|
||||
let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?;
|
||||
let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) };
|
||||
if sig != 0x0403_4b50u32.to_le_bytes() {
|
||||
return Err(damaged());
|
||||
}
|
||||
let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged());
|
||||
let start = e.local + 30 + len(name_len)? + len(extra)?;
|
||||
let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?;
|
||||
let bytes = match e.method {
|
||||
0 if e.packed != e.size => return Err(damaged()),
|
||||
0 => data.to_vec(),
|
||||
8 => {
|
||||
let mut out = Vec::with_capacity(e.size);
|
||||
flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?;
|
||||
if out.len() > max {
|
||||
return Err(ReadError::TooLarge(e.name.clone()));
|
||||
}
|
||||
out
|
||||
}
|
||||
m => return Err(ReadError::Method(e.name.clone(), m)),
|
||||
};
|
||||
let mut crc = Crc::new();
|
||||
crc.update(&bytes);
|
||||
if bytes.len() != e.size || crc.sum() != e.crc {
|
||||
return Err(damaged());
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of a store-only archive written by [`store`] (read through its central directory,
|
||||
/// checking every CRC) → `(name, bytes)`.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Flatten Transparency…",
|
||||
["Object"],
|
||||
None,
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
|
||||
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
@@ -259,13 +259,19 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
|
||||
}
|
||||
let src = s.doc()?.doc.clone();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let plan = plan(&src, &roots, &o, true)?;
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
let options = serde_json::to_value(&o).unwrap_or_default();
|
||||
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
|
||||
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
|
||||
}
|
||||
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options }))
|
||||
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---------- presets ----------
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Font List",
|
||||
[],
|
||||
None,
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
"{family?, style?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names] (upright styles by weight, then italics; an error when the family isn't available), axes: [{tag, name, min, default, max, value, hidden}] (a variable font's variation axes, the Character panel's Variable Font sliders, with `value` the setting of `style`, else of the first style; empty for a static font; `hidden`: the font asks for the axis not to be shown)}",
|
||||
always,
|
||||
font_list
|
||||
),
|
||||
@@ -136,6 +136,19 @@ pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -
|
||||
(walk.fonts, walk.overflow)
|
||||
}
|
||||
|
||||
/// The warning of an export that draws `layers` of `d` over `areas` (document space; `None`: the
|
||||
/// whole of them) about the area type and type on a path that it draws and whose text doesn't fit,
|
||||
/// found as [`printed_type`] finds them for Print: visible objects of the document that aren't on
|
||||
/// a template layer.
|
||||
pub(super) fn export_overflow_warning(d: &Document, layers: &[Arc<Node>], areas: Option<&[Rect]>) -> Option<String> {
|
||||
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||||
for l in layers {
|
||||
walk.node(l, areas);
|
||||
}
|
||||
let overflow: Vec<NodeId> = walk.overflow.into_iter().collect();
|
||||
super::typecmd::export_overflow_warning(&overflow)
|
||||
}
|
||||
|
||||
/// The walk of [`printed_type`].
|
||||
struct Printed<'a> {
|
||||
d: &'a Document,
|
||||
@@ -395,8 +408,9 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.unwrap_or_else(|| r.style.font_style.clone());
|
||||
r.style.font_family = tf.clone();
|
||||
r.style.font_style = style;
|
||||
// Another font: none of the old one's versions.
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
r.style.font_version = None;
|
||||
r.style.font_axes.clear();
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
@@ -440,7 +454,23 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The name as the font spells it.
|
||||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||||
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
||||
let styles = db.styles(family);
|
||||
let style = str_param(p, "style").map(str::to_string).or_else(|| styles.first().cloned()).unwrap_or_else(|| "Regular".into());
|
||||
let axes: Vec<Value> = db
|
||||
.face(family, &style)
|
||||
.map(|face| {
|
||||
let axes = face.axes();
|
||||
axes.iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"tag": String::from_utf8_lossy(&a.tag), "name": a.name, "min": a.min, "default": a.default, "max": a.max,
|
||||
"value": face.axis_value(a.tag).unwrap_or(a.default), "hidden": a.hidden,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(json!({ "family": name, "styles": styles, "axes": axes }))
|
||||
}
|
||||
None => Ok(json!({ "families": *db.menu_family_list() })),
|
||||
}
|
||||
@@ -462,16 +492,14 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
if !texts.is_empty() {
|
||||
let saved = !d.is_dirty();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
d.change_unsaved(|doc| {
|
||||
let doc = std::sync::Arc::make_mut(doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if saved {
|
||||
d.mark_saved();
|
||||
}
|
||||
});
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
|
||||
+713
-27
@@ -11,9 +11,10 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, CharStyle, Document, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, TickLength, ValueAxisSide,
|
||||
Appearance, CharStyle, ColumnDesign, ColumnType, Document, Fractions, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind,
|
||||
TextObject, TickLength, ValueAxisSide,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
@@ -56,6 +57,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
design
|
||||
),
|
||||
cmd!(
|
||||
"graph.column",
|
||||
"Column…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
|
||||
has_selection,
|
||||
set_column
|
||||
),
|
||||
cmd!(
|
||||
"graph.marker",
|
||||
"Marker…",
|
||||
@@ -311,6 +321,8 @@ struct Gen<'a> {
|
||||
out: Vec<Arc<Node>>,
|
||||
/// The labels' kept character styles.
|
||||
styles: &'a vectorcraft_doc::LabelStyles,
|
||||
/// What the column designs may still add.
|
||||
columns: Allowance,
|
||||
}
|
||||
|
||||
/// What a graph label shows, for its kept character style.
|
||||
@@ -397,10 +409,26 @@ impl Gen<'_> {
|
||||
/// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint.
|
||||
fn marker(&mut self, design: Option<&Arc<Node>>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc<Node> {
|
||||
match design.and_then(|a| fit_design(self.d, a, cell)) {
|
||||
Some(n) => n,
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.path(shapes::rectangle(cell), fill, stroke, w),
|
||||
}
|
||||
}
|
||||
/// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`].
|
||||
fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
|
||||
let fitted = match column {
|
||||
Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell),
|
||||
_ => None,
|
||||
};
|
||||
match fitted {
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.marker(marker, cell, paint),
|
||||
}
|
||||
}
|
||||
/// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the
|
||||
/// paths directly inside its group and the design keeps its own paint.
|
||||
fn design_copy(&mut self, n: Arc<Node>) -> Arc<Node> {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n }
|
||||
}
|
||||
/// A series group. Empty series are omitted by the caller; the index is stored on the group
|
||||
/// so later paint edits find the series without counting siblings.
|
||||
fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
@@ -495,24 +523,260 @@ fn marker_cost(g: &GraphSpec, designs: &[Option<Arc<Node>>]) -> usize {
|
||||
designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// The frame graph design `art` is drawn around: its backmost object's bounds (the whole art's when the design is a
|
||||
/// single object). `None` for art with no area.
|
||||
fn design_frame(art: &Node) -> Option<Rect> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
backmost.or_else(|| art.geometric_bounds().filter(usable))
|
||||
}
|
||||
|
||||
/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is
|
||||
/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it.
|
||||
/// Fresh ids. `None` for art with no area.
|
||||
fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
let frame = backmost.or_else(|| art.geometric_bounds().filter(usable))?;
|
||||
let a = Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2());
|
||||
let a = fit_affine(design_frame(art)?, cell);
|
||||
// Strokes, effects and pattern tiles scale with the art.
|
||||
let art = super::place::transformed(art.clone(), a);
|
||||
Some(Arc::new(d.reid(&art)))
|
||||
}
|
||||
|
||||
/// Build the graph's children.
|
||||
/// The map that puts a design's frame `frame` on `cell` ([`fit_design`]).
|
||||
fn fit_affine(frame: Rect, cell: Rect) -> Affine {
|
||||
Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2())
|
||||
}
|
||||
|
||||
/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy).
|
||||
const COLUMN_BUDGET: usize = 200_000;
|
||||
|
||||
/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of
|
||||
/// those guides.
|
||||
struct ColumnArt {
|
||||
options: ColumnDesign,
|
||||
art: Node,
|
||||
guides: Vec<Rect>,
|
||||
}
|
||||
|
||||
/// `art` without the paths made into guides, at any depth.
|
||||
fn without_guides(art: &Node) -> Node {
|
||||
let mut n = art.clone();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old = std::mem::take(ch);
|
||||
*ch = old.iter().filter(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })).map(|c| Arc::new(without_guides(c))).collect();
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Each series' column design, when the document has the design.
|
||||
fn column_designs(d: &Document, g: &GraphSpec) -> Vec<Option<ColumnArt>> {
|
||||
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
(0..nser)
|
||||
.map(|s| {
|
||||
let options = g.series_columns.get(s)?.as_ref()?;
|
||||
let art = &d.graph_designs.iter().find(|x| x.name == options.design)?.art;
|
||||
let mut guides = vec![];
|
||||
art.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
guides.push(b);
|
||||
}
|
||||
});
|
||||
Some(ColumnArt { options: options.clone(), art: without_guides(art), guides })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The value one copy of a repeating design stands for, when the design repeats and the value is usable.
|
||||
fn repeat_each(c: &ColumnDesign) -> Option<f64> {
|
||||
(c.column_type == ColumnType::Repeating && c.each.is_finite() && c.each > 0.0).then_some(c.each)
|
||||
}
|
||||
|
||||
/// The objects a graph's column designs may still add (out of [`COLUMN_BUDGET`]); `short` once a copy needed more than
|
||||
/// was left, and the graph is then drawn with plain columns.
|
||||
struct Allowance {
|
||||
left: usize,
|
||||
short: bool,
|
||||
}
|
||||
|
||||
impl Allowance {
|
||||
fn new() -> Self {
|
||||
Self { left: COLUMN_BUDGET, short: false }
|
||||
}
|
||||
/// Spend `n` objects, when that many are left.
|
||||
fn take(&mut self, n: usize) -> bool {
|
||||
match self.left.checked_sub(n) {
|
||||
Some(rest) => {
|
||||
self.left = rest;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.short = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies
|
||||
/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above
|
||||
/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per
|
||||
/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain.
|
||||
fn column_art(
|
||||
d: &mut Document,
|
||||
c: &ColumnArt,
|
||||
rect: Rect,
|
||||
(horizontal, far_low): (bool, bool),
|
||||
per_unit: f64,
|
||||
left: &mut Allowance,
|
||||
) -> Option<Arc<Node>> {
|
||||
let frame = design_frame(&c.art)?;
|
||||
let length = |r: Rect| if horizontal { r.width() } else { r.height() };
|
||||
let thickness = |r: Rect| if horizontal { r.height() } else { r.width() };
|
||||
if length(rect) <= 1e-9 || !length(rect).is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cost = c.art.count();
|
||||
// A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when
|
||||
// `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more).
|
||||
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None };
|
||||
let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| {
|
||||
let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?;
|
||||
// The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed
|
||||
// stroke counts only in its own object's box.
|
||||
let mut b = vectorcraft_render::painted_bounds(d, &art);
|
||||
art.walk(&mut |n| {
|
||||
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
|
||||
b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n));
|
||||
}
|
||||
});
|
||||
let b = b.or_else(|| art.geometric_bounds())?;
|
||||
let keep = match (along_x, keep_low) {
|
||||
(true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1),
|
||||
(true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1),
|
||||
(false, true) => Rect::new(b.x0, b.y0, b.x1, at.max(b.y0)),
|
||||
(false, false) => Rect::new(b.x0, at.min(b.y1), b.x1, b.y1),
|
||||
};
|
||||
Some(Arc::new(super::rasterfx::clip_group(d, shapes::rectangle(keep), FillRule::NonZero, art)))
|
||||
};
|
||||
// The part of the column from `a` to `b` points out from its zero end.
|
||||
let piece = |a: f64, b: f64| match (horizontal, far_low) {
|
||||
(true, true) => Rect::new(rect.x1 - b, rect.y0, rect.x1 - a, rect.y1),
|
||||
(true, false) => Rect::new(rect.x0 + a, rect.y0, rect.x0 + b, rect.y1),
|
||||
(false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a),
|
||||
(false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b),
|
||||
};
|
||||
let mut parts = vec![];
|
||||
match c.options.column_type {
|
||||
ColumnType::Uniform => {
|
||||
// Scaled both ways to the column's length, centered across it.
|
||||
let t = thickness(frame) * length(rect) / length(frame) / 2.0;
|
||||
let cell = if horizontal {
|
||||
Rect::new(rect.x0, rect.center().y - t, rect.x1, rect.center().y + t)
|
||||
} else {
|
||||
Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1)
|
||||
};
|
||||
parts.push(fit(d, cell, left)?);
|
||||
}
|
||||
ColumnType::Repeating => {
|
||||
// When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design
|
||||
// is drawn vertically scaled.
|
||||
let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else {
|
||||
parts.push(fit(d, rect, left)?);
|
||||
return Some(Arc::new(Node::group(d.alloc_id(), parts)));
|
||||
};
|
||||
// The whole copies, and the points of the column left for the last one (none under a millionth of a point).
|
||||
let whole = ((length(rect) + 1e-6) / unit).floor();
|
||||
let rest = length(rect) - whole * unit;
|
||||
// Each copy is spent from `left`, which stops the loop.
|
||||
for i in 0..whole as usize {
|
||||
parts.push(fit(d, piece(i as f64 * unit, (i + 1) as f64 * unit), left)?);
|
||||
}
|
||||
if rest > 1e-6 {
|
||||
let start = whole * unit;
|
||||
match c.options.fractions {
|
||||
Fractions::Scale => parts.push(fit(d, piece(start, start + rest), left)?),
|
||||
Fractions::Chop => {
|
||||
// The last copy cut off where the column ends, and nowhere else.
|
||||
let cut = match (horizontal, far_low) {
|
||||
(true, true) => (true, false, rect.x0),
|
||||
(true, false) => (true, true, rect.x1),
|
||||
(false, true) => (false, false, rect.y0),
|
||||
(false, false) => (false, true, rect.y1),
|
||||
};
|
||||
parts.push(clipped(d, fit_affine(frame, piece(start, start + unit)), cut, left)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnType::Sliding => match slide(frame, &c.guides, rect, horizontal) {
|
||||
Some((kept, stretched, at)) => {
|
||||
// Each copy cut at the guide, on its own side.
|
||||
parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?);
|
||||
parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?);
|
||||
}
|
||||
None => parts.push(fit(d, rect, left)?),
|
||||
},
|
||||
ColumnType::Vertical => parts.push(fit(d, rect, left)?),
|
||||
}
|
||||
Some(Arc::new(Node::group(d.alloc_id(), parts)))
|
||||
}
|
||||
|
||||
/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first
|
||||
/// horizontal guide in `guides` inside the frame (in a bar, right of the first vertical one), scaled in proportion and
|
||||
/// put at the rect's top (in a bar, its right end), the value end for values above zero; the map of the part below (left
|
||||
/// of) it, stretched or compressed to fill the rest; and where the guide lands (y, or x in a bar). Both maps put the guide there. `None` without such a guide, or when the column is too
|
||||
/// short for the part kept in proportion.
|
||||
fn slide(frame: Rect, guides: &[Rect], rect: Rect, horizontal: bool) -> Option<(Affine, Affine, f64)> {
|
||||
// A horizontal line (in a bar, a vertical one): longer than it is across, and at most a hundredth of the frame across.
|
||||
let inside = |g: &&Rect| {
|
||||
if horizontal {
|
||||
g.width() < g.height() && g.width() <= frame.width() / 100.0 && g.center().x > frame.x0 && g.center().x < frame.x1
|
||||
} else {
|
||||
g.height() < g.width() && g.height() <= frame.height() / 100.0 && g.center().y > frame.y0 && g.center().y < frame.y1
|
||||
}
|
||||
};
|
||||
let g = guides.iter().find(inside)?.center();
|
||||
if horizontal {
|
||||
let s = rect.height() / frame.height();
|
||||
let (kept, stretched) = (frame.x1 - g.x, g.x - frame.x0);
|
||||
let rest = rect.width() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x1 - frame.x1 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([k, 0.0, 0.0, s, rect.x0 - frame.x0 * k, rect.y0 - frame.y0 * s]);
|
||||
Some((kept_map, stretched_map, rect.x1 - kept * s))
|
||||
} else {
|
||||
let s = rect.width() / frame.width();
|
||||
let (kept, stretched) = (g.y - frame.y0, frame.y1 - g.y);
|
||||
let rest = rect.height() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x0 - frame.x0 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([s, 0.0, 0.0, k, rect.x0 - frame.x0 * s, rect.y1 - frame.y1 * k]);
|
||||
Some((kept_map, stretched_map, rect.y0 + kept * s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it,
|
||||
/// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects.
|
||||
fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let column_looks = column_designs(d, g);
|
||||
match generate_with(d, g, &column_looks) {
|
||||
Some(out) => out,
|
||||
None => generate_with(d, g, &[]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects.
|
||||
fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnArt>]) -> Option<Vec<Arc<Node>>> {
|
||||
let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width);
|
||||
// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all
|
||||
// when the designs would draw more than MARKER_BUDGET objects.
|
||||
@@ -521,7 +785,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
designs.clear();
|
||||
}
|
||||
let design = |s: usize| designs.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles };
|
||||
let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() };
|
||||
// The labels' sizes (9 pt unless formatted), which their spacing follows.
|
||||
let (vs, cs, ls) =
|
||||
(label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend));
|
||||
@@ -538,6 +803,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
|
||||
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
|
||||
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
|
||||
// Series drawn as columns or bars, which column designs apply to.
|
||||
let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar);
|
||||
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
|
||||
// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it.
|
||||
let mut right_edge = r.x1;
|
||||
@@ -759,6 +1026,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (lo, hi, _) = scale(a);
|
||||
vpos(a, 0.0f64.clamp(lo, hi))
|
||||
};
|
||||
// Plot points per unit of value along value axis `a`.
|
||||
let per_unit = |a: usize| {
|
||||
let (lo, hi, _) = scale(a);
|
||||
(if horizontal { r.width() } else { r.height() }) / (hi - lo)
|
||||
};
|
||||
let mut axes = vec![];
|
||||
// Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right
|
||||
// one shows the right scale).
|
||||
@@ -964,10 +1236,24 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
let rect = bar_rect(a, bar, zero(axis(s)), vpos(axis(s), v));
|
||||
let (v0, v1) = (zero(axis(s)), vpos(axis(s), v));
|
||||
let rect = bar_rect(a, bar, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
// A column drawn with a design casts no shadow.
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
// A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too.
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -995,11 +1281,22 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
let rect = bar_rect(a, w, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -1081,7 +1378,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
for (s, label, w) in row {
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line;
|
||||
let paint = mark(*s, true, Paint::None, 0.0);
|
||||
let swatch = b.marker(design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
let swatch =
|
||||
b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
if let Some(items) = series.get_mut(*s) {
|
||||
items.push(swatch);
|
||||
}
|
||||
@@ -1103,10 +1401,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
}
|
||||
y += ls * 1.8 * label.split('|').count() as f64;
|
||||
let y = row_y;
|
||||
// Series drawn with markers show their marker design in the legend.
|
||||
// Series drawn with markers show their marker design in the legend, and series drawn with a column design
|
||||
// that design.
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line;
|
||||
let paint = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.marker(design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false));
|
||||
}
|
||||
}
|
||||
@@ -1153,7 +1452,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let grp = b.group("Legend", labels);
|
||||
b.out.push(grp);
|
||||
}
|
||||
b.out
|
||||
(!b.columns.short).then_some(b.out)
|
||||
}
|
||||
|
||||
fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
|
||||
@@ -1499,11 +1798,16 @@ fn design_names(d: &Document) -> Vec<String> {
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Whether graph `g` draws a series with design `name`, as its marker or its columns.
|
||||
fn draws_with(g: &GraphSpec, name: &str) -> bool {
|
||||
g.series_markers.iter().flatten().any(|m| m == name) || g.series_columns.iter().flatten().any(|c| c.design == name)
|
||||
}
|
||||
|
||||
/// Regenerate the graphs whose series use design `name` (it was added or removed).
|
||||
fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, name)) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1521,6 +1825,7 @@ fn unused_designs(d: &Document) -> Vec<String> {
|
||||
d.walk(|n| {
|
||||
if let Some(g) = n.extras().graph.as_deref() {
|
||||
used.extend(g.series_markers.iter().flatten().cloned());
|
||||
used.extend(g.series_columns.iter().flatten().map(|c| c.design.clone()));
|
||||
}
|
||||
});
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect()
|
||||
@@ -1594,7 +1899,7 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut graphs = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, &old)) {
|
||||
graphs.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1603,6 +1908,9 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) {
|
||||
*m = new.clone();
|
||||
}
|
||||
for c in g.series_columns.iter_mut().flatten().filter(|c| c.design == old) {
|
||||
c.design = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1693,6 +2001,74 @@ fn set_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.column";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
let picked = picked_series(s, p, id, count, C)?;
|
||||
let names = design_names(&s.doc()?.doc);
|
||||
let all: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked.clone() };
|
||||
// The column design the picked series (all of them with none picked) share; a design deleted since doesn't count,
|
||||
// as those series draw plain columns.
|
||||
let used: Vec<Option<&ColumnDesign>> =
|
||||
all.iter().map(|i| spec.series_columns.get(*i).and_then(Option::as_ref).filter(|c| names.contains(&c.design))).collect();
|
||||
let shared = used.first().copied().flatten().filter(|c| used.iter().all(|x| *x == Some(*c))).cloned();
|
||||
let Some(v) = p.get("design") else {
|
||||
let options = shared.clone().unwrap_or_default();
|
||||
return Ok(json!({
|
||||
"designs": names,
|
||||
"design": shared.map(|c| c.design),
|
||||
"columnType": options.column_type.id(),
|
||||
"each": options.each,
|
||||
"fractions": options.fractions.id(),
|
||||
}));
|
||||
};
|
||||
let design = match v {
|
||||
Value::Null => None,
|
||||
Value::String(n) if names.contains(n) => Some(n.clone()),
|
||||
Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))),
|
||||
_ => return Err(bad(C, "`design` is a design name, or null for plain columns")),
|
||||
};
|
||||
let mut options = shared.unwrap_or_default();
|
||||
if let Some(t) = p.get("columnType") {
|
||||
options.column_type =
|
||||
t.as_str().and_then(ColumnType::parse).ok_or_else(|| bad(C, "`columnType` is vertical, uniform, repeating or sliding"))?;
|
||||
}
|
||||
if let Some(e) = p.get("each") {
|
||||
options.each =
|
||||
e.as_f64().filter(|e| e.is_finite() && *e > 0.0).ok_or_else(|| bad(C, "`each` is the value one design stands for, more than 0"))?;
|
||||
}
|
||||
if let Some(f) = p.get("fractions") {
|
||||
options.fractions = f.as_str().and_then(Fractions::parse).ok_or_else(|| bad(C, "`fractions` is chop or scale"))?;
|
||||
}
|
||||
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
|
||||
spec.series_columns.truncate(count);
|
||||
spec.series_columns.resize(count, None);
|
||||
for i in all {
|
||||
if let Some(c) = spec.series_columns.get_mut(i) {
|
||||
*c = design.clone().map(|design| ColumnDesign { design, ..options.clone() });
|
||||
}
|
||||
}
|
||||
while spec.series_columns.last().is_some_and(Option::is_none) {
|
||||
spec.series_columns.pop();
|
||||
}
|
||||
// Drawn once on a copy of the document, to refuse designs past the budget before the edit.
|
||||
let mut probe = (*s.doc()?.doc).clone();
|
||||
let looks = column_designs(&probe, &spec);
|
||||
if generate_with(&mut probe, &spec, &looks).is_none() {
|
||||
return Err(bad(C, "the design is too big to draw in every column of this graph"));
|
||||
}
|
||||
s.edit("Graph Column", |d, sel| {
|
||||
regenerate(d, id, spec)?;
|
||||
if !reselect.is_empty() {
|
||||
reselect_series(d, sel, id, &reselect);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.setType";
|
||||
let id = target(s, p, C)?;
|
||||
@@ -1920,7 +2296,7 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, GraphKind, GraphSpec, NodeKind};
|
||||
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
|
||||
@@ -3365,6 +3741,16 @@ mod tests {
|
||||
assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(design_fill(&s), painted);
|
||||
// A design of one path too.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Square"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.marker", &json!({"design": "Square"})).unwrap();
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3414,6 +3800,306 @@ mod tests {
|
||||
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
|
||||
}
|
||||
|
||||
/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle
|
||||
/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`.
|
||||
fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) {
|
||||
let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| {
|
||||
s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)];
|
||||
ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top));
|
||||
if guide {
|
||||
let line =
|
||||
s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.push(line);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": name})).unwrap();
|
||||
}
|
||||
|
||||
/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero
|
||||
/// line at y = 300.
|
||||
fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId {
|
||||
let rows: Vec<Vec<f64>> = values.iter().map(|v| vec![*v]).collect();
|
||||
let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows});
|
||||
let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it.
|
||||
fn frames(n: &vectorcraft_doc::Node) -> Vec<vectorcraft_geom::Rect> {
|
||||
n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect()
|
||||
}
|
||||
|
||||
fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool {
|
||||
[a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap();
|
||||
// 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value.
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let parts = column.children().unwrap();
|
||||
assert_eq!(parts.len(), 4, "3.5 designs");
|
||||
let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1];
|
||||
let copies = frames(&column);
|
||||
assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}");
|
||||
let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) };
|
||||
assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy");
|
||||
let (clip, copy) =
|
||||
(children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap());
|
||||
assert_eq!(clip.y0, 160.0, "cut off at the value");
|
||||
// Its sides and its foot, strokes included, are not cut.
|
||||
assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}");
|
||||
// Nor is the brush art of a brushed path inside a group of the design.
|
||||
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
|
||||
let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0));
|
||||
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
|
||||
for item in &mut look.items {
|
||||
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item {
|
||||
st.brush = Some("Wide".into());
|
||||
}
|
||||
}
|
||||
let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look);
|
||||
let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]);
|
||||
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]);
|
||||
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) });
|
||||
let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() };
|
||||
let g = GraphSpec {
|
||||
rows: vec![vec![35.0]],
|
||||
axis_min: Some(0.0),
|
||||
axis_max: Some(50.0),
|
||||
series_columns: vec![Some(options)],
|
||||
..GraphSpec::default()
|
||||
};
|
||||
let out = super::generate(&mut d, &g);
|
||||
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
|
||||
let last = column.children().unwrap().last().unwrap().clone();
|
||||
let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) };
|
||||
let clip = children[0].geometric_bounds().unwrap();
|
||||
let mut reach = None;
|
||||
children[1].walk(&mut |n| {
|
||||
if vectorcraft_brush::has_brush(n) {
|
||||
reach = vectorcraft_render::painted_bounds(&d, n);
|
||||
}
|
||||
});
|
||||
let reach = reach.unwrap();
|
||||
assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}");
|
||||
// Scale: the last copy half as tall instead.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap();
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let copies = frames(&column);
|
||||
assert_eq!(copies.len(), 4);
|
||||
assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}");
|
||||
let c = &spec(&s, id).series_columns[0];
|
||||
assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale)));
|
||||
// A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line.
|
||||
let bars = one_series(&mut s, "bar", &[-15.0]);
|
||||
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap();
|
||||
let bar = series_parts(&s, bars, 0)[0].clone();
|
||||
let copies = frames(&bar);
|
||||
// The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250.
|
||||
let (zero, unit) = (250.0, 3.0);
|
||||
assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
// 10 wide and 20 tall, the guide 5 points from the top.
|
||||
save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap();
|
||||
let parts = series_parts(&s, id, 0);
|
||||
for (column, value_end) in parts.iter().zip([160.0, 220.0]) {
|
||||
// The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands
|
||||
// 12 points (2.4 times 5) below the value end.
|
||||
let halves = column.children().unwrap();
|
||||
assert_eq!(halves.len(), 2, "{halves:?}");
|
||||
let half = |i: usize| {
|
||||
let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) };
|
||||
(children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec())
|
||||
};
|
||||
let ((above, kept), (below, stretched)) = (half(0), half(1));
|
||||
let at = value_end + 12.0;
|
||||
assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}");
|
||||
assert_eq!(kept.len(), 3, "the guide is not drawn");
|
||||
// Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end.
|
||||
let top = kept[1].geometric_bounds().unwrap();
|
||||
assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}");
|
||||
// Below it the design is stretched down to the zero line.
|
||||
let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap());
|
||||
assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}");
|
||||
assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}");
|
||||
}
|
||||
// Vertically Scaled stretches the whole design, the top part included.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Without a guide a sliding design is scaled vertically too.
|
||||
save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false);
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before
|
||||
// the line, are passed over.
|
||||
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let mut ids = vec![
|
||||
id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()),
|
||||
id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()),
|
||||
];
|
||||
let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap());
|
||||
let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap());
|
||||
let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.extend([square, tick, line]);
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Boxed"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap();
|
||||
let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec();
|
||||
let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap();
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap();
|
||||
for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) {
|
||||
let frame = frames(column)[0];
|
||||
assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}");
|
||||
assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6);
|
||||
}
|
||||
assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column");
|
||||
// A design twice as wide as it is tall reaches past the plot, and the legend starts right of it.
|
||||
save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false);
|
||||
let wide = one_series(&mut s, "column", &[20.0, 35.0]);
|
||||
s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap();
|
||||
let parts = series_parts(&s, wide, 0);
|
||||
let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap());
|
||||
assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns");
|
||||
// The legend swatch shows the design, its frame filling the 8-point square.
|
||||
let swatch = series_parts(&s, id, 0).last().unwrap().clone();
|
||||
let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}");
|
||||
// Painting the series leaves the design's paint alone.
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
let mut red = false;
|
||||
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000"));
|
||||
assert!(!red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_designs_survive_save_and_open_and_follow_their_design() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = graph(&mut s, "stackedColumn");
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
|
||||
// Every series takes the design; only the stacked column series draws it.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"});
|
||||
s.execute("graph.column", &p).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch");
|
||||
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
|
||||
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
|
||||
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
|
||||
let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
|
||||
assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]);
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none());
|
||||
// Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again.
|
||||
s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token"));
|
||||
assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([]));
|
||||
s.execute("graph.design", &json!({"delete": "Token"})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })));
|
||||
// Null goes back to plain columns.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
for p in [
|
||||
json!({"design": "Nope"}),
|
||||
json!({"design": 3}),
|
||||
json!({"design": "Coin", "columnType": "diagonal"}),
|
||||
json!({"design": "Coin", "each": 0}),
|
||||
json!({"design": "Coin", "each": -1}),
|
||||
json!({"design": "Coin", "each": "10"}),
|
||||
json!({"design": "Coin", "fractions": "round"}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["id"] = json!(id.0);
|
||||
assert!(s.execute("graph.column", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
// 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns.
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001});
|
||||
assert!(s.execute("graph.column", &p).is_err());
|
||||
let mut g = spec(&s, id);
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop };
|
||||
g.series_columns = vec![Some(options.clone())];
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
// Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each
|
||||
// 0.1 is past the budget too.
|
||||
let far = one_series(&mut s, "column", &[0.0, 0.0]);
|
||||
s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err());
|
||||
assert!(spec(&s, far).series_columns.is_empty());
|
||||
// A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas:
|
||||
// 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy.
|
||||
for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] {
|
||||
g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })];
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
let column = s0.children().unwrap()[0].clone();
|
||||
assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}");
|
||||
assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}");
|
||||
assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped");
|
||||
}
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
|
||||
NodeId(
|
||||
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Vector Halftone…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_selection,
|
||||
halftone
|
||||
)]
|
||||
@@ -185,11 +185,13 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let (w, h) = vectorcraft_render::region_pixels(region, scale);
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let px = if mono {
|
||||
r.render_region(&tmp, region, scale, true).pixels
|
||||
} else {
|
||||
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
|
||||
};
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
if px.len() < w * h * 4 {
|
||||
return Err(EngineError::Other("the art could not be sampled".into()));
|
||||
}
|
||||
@@ -241,7 +243,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "dots": dots }))
|
||||
let mut out = json!({ "id": id.0, "dots": dots });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The dots of one screen over `bounds` as one path, with their count.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Window → SVG Interactivity: the JavaScript an object runs on each event in web output
|
||||
//! (`svgInteractivity.setEvent`, `removeEvent`, `clearEvents`) and the JavaScript files the
|
||||
//! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as
|
||||
//! event attributes (`onclick="…"`) and the files as `<script xlink:href>` elements; SVG import
|
||||
//! reads both back as text. The panel reads everything with `svgInteractivity.info`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
|
||||
|
||||
use super::overprint::edit_counted;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "svgInteractivity.info",
|
||||
"SVG Interactivity",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.setEvent",
|
||||
"Add Event",
|
||||
[],
|
||||
None,
|
||||
"{event: \"onclick\"|\"onmouseover\"|… (svgInteractivity.info's eventNames), script: string (the JavaScript; \"\" removes the event), ids?|id?} set the JavaScript an event runs on ids or the selection, replacing the event's script where it has one, as one undo step; SVG export writes it as the event attribute → {changed: objects}",
|
||||
has_doc,
|
||||
set_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeEvent",
|
||||
"Remove Selected Entry",
|
||||
[],
|
||||
None,
|
||||
"{event, ids?|id?} remove an event and its JavaScript from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
remove_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.clearEvents",
|
||||
"Clear Events",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} remove every event from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
clear_events
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.addFile",
|
||||
"Add JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked stays where it is; a document links at most 64 files → {files}",
|
||||
has_doc,
|
||||
add_file
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeFile",
|
||||
"Remove JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file} unlink a JavaScript file from the document → {files}",
|
||||
has_doc,
|
||||
remove_file
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `p`'s `event`, one of [`SVG_EVENTS`] (any case).
|
||||
fn event_param(p: &Value, cmd: &str) -> Result<&'static str> {
|
||||
let e = str_param(p, "event").ok_or_else(|| bad(cmd, "give `event`, such as onclick"))?;
|
||||
SVG_EVENTS
|
||||
.iter()
|
||||
.find(|n| n.eq_ignore_ascii_case(e.trim()))
|
||||
.copied()
|
||||
.ok_or_else(|| bad(cmd, format!("`event` must be one of {}", SVG_EVENTS.join(", "))))
|
||||
}
|
||||
|
||||
/// The events of object `id` in `d` (none for a missing object).
|
||||
fn events_of(d: &Document, id: NodeId) -> Vec<(String, String)> {
|
||||
d.node(id).and_then(|n| n.extras().attrs.as_deref()).map(|a| a.events.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the events of `ids` (or the selection) with `f`, as one undo step named `label`; an
|
||||
/// object whose events `f` leaves as they were isn't changed → `{changed: objects}`.
|
||||
fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&mut Vec<(String, String)>)) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select the objects (or give their ids)"));
|
||||
}
|
||||
let changed = edit_counted(s, label, |d| {
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
let mut events = events_of(d, *id);
|
||||
let before = events.clone();
|
||||
f(&mut events);
|
||||
if events != before
|
||||
&& let Some(n) = d.node_mut(*id)
|
||||
{
|
||||
n.edit_attrs(|a| a.events = events);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
|
||||
let events = lists
|
||||
.first()
|
||||
.filter(|first| lists.iter().all(|l| l == *first))
|
||||
.map(|l| l.iter().map(|(event, script)| json!({ "event": event, "script": script })).collect::<Vec<_>>());
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"events": events,
|
||||
"files": d.svg_scripts,
|
||||
"eventNames": SVG_EVENTS,
|
||||
}))
|
||||
}
|
||||
|
||||
fn set_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.setEvent";
|
||||
let event = event_param(p, C)?;
|
||||
let script = p.get("script").map(|v| v.as_str().ok_or_else(|| bad(C, "`script` must be a string"))).transpose()?;
|
||||
let script = script.ok_or_else(|| bad(C, "give `script`, the JavaScript the event runs (\"\" removes the event)"))?.trim().to_string();
|
||||
edit_events(s, p, C, "Add Event", |events| {
|
||||
if script.is_empty() {
|
||||
events.retain(|(e, _)| e != event);
|
||||
} else if let Some((_, js)) = events.iter_mut().find(|(e, _)| e == event) {
|
||||
js.clone_from(&script);
|
||||
} else {
|
||||
events.push((event.to_string(), script.clone()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeEvent";
|
||||
let event = event_param(p, C)?;
|
||||
edit_events(s, p, C, "Remove Selected Entry", |events| events.retain(|(e, _)| e != event))
|
||||
}
|
||||
|
||||
fn clear_events(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_events(s, p, "svgInteractivity.clearEvents", "Clear Events", Vec::clear)
|
||||
}
|
||||
|
||||
/// `p`'s `file`, trimmed and not empty.
|
||||
fn file_param(p: &Value, cmd: &str) -> Result<String> {
|
||||
let f = str_param(p, "file").map(str::trim).filter(|f| !f.is_empty());
|
||||
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
|
||||
}
|
||||
|
||||
fn files(s: &Session) -> Result<Value> {
|
||||
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
|
||||
}
|
||||
|
||||
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.addFile";
|
||||
let file = file_param(p, C)?;
|
||||
let linked = &s.doc()?.doc.svg_scripts;
|
||||
if !linked.contains(&file) {
|
||||
if linked.len() >= MAX_SVG_SCRIPTS {
|
||||
return Err(bad(C, format!("a document links at most {MAX_SVG_SCRIPTS} JavaScript files")));
|
||||
}
|
||||
s.edit("Add JavaScript File", |d, _| {
|
||||
d.svg_scripts.push(file);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
files(s)
|
||||
}
|
||||
|
||||
fn remove_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeFile";
|
||||
let file = file_param(p, C)?;
|
||||
if !s.doc()?.doc.svg_scripts.contains(&file) {
|
||||
return Err(bad(C, format!("{file:?} isn't linked")));
|
||||
}
|
||||
s.edit("Remove JavaScript File", |d, _| {
|
||||
d.svg_scripts.retain(|f| *f != file);
|
||||
Ok(())
|
||||
})?;
|
||||
files(s)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
@@ -810,7 +810,8 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_from(p, def);
|
||||
let idx = s.edit("New Artboard", |d, _| {
|
||||
let id = d.next_artboard_id();
|
||||
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1));
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
|
||||
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
|
||||
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
|
||||
Ok(d.artboards.len() - 1)
|
||||
})?;
|
||||
@@ -864,8 +865,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Artboard Options", |d, _| {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = now;
|
||||
if let Some(n) = str_param(p, "name") {
|
||||
a.name = n.to_string();
|
||||
if let Some(n) = str_param(p, "name").map(str::trim) {
|
||||
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
|
||||
a.name = if n.is_empty() { format!("Artboard {}", i + 1) } else { n.to_string() };
|
||||
}
|
||||
let id = a.id;
|
||||
if let Some(xf) = transform {
|
||||
|
||||
@@ -391,11 +391,12 @@ fn remove_file(_: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
|
||||
/// kept, other characters as `-` ("Library" when nothing is left).
|
||||
/// kept, other characters as `-`, and a `-` after a Windows device name such as `CON` or `COM1`
|
||||
/// (`fileio::safe_file_name`); "Library" when nothing is left.
|
||||
pub fn file_stem_for(name: &str) -> String {
|
||||
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let safe = safe.trim();
|
||||
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
|
||||
if safe.is_empty() { "Library".to_string() } else { super::fileio::safe_file_name(safe) }
|
||||
}
|
||||
|
||||
fn stem_of(path: &str) -> String {
|
||||
@@ -884,7 +885,7 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
|
||||
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
|
||||
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let doc = &s.doc()?.doc;
|
||||
// The document's style of that name when it is the same; else a new one.
|
||||
|
||||
+114
-20
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
|
||||
PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
@@ -96,12 +96,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
spine_move_anchor
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.convertAnchor",
|
||||
"Convert Anchor Point",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected blend), anchor: index, to: smooth|corner, x?, y?} convert a blend spine point: dragging a corner creates symmetric smooth handles, clicking a point with handles retracts both. Without x/y, smooth uses the neighbouring spine points",
|
||||
has_doc,
|
||||
spine_convert_anchor
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.reshapeSegment",
|
||||
"Reshape Spine Segment",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy, perpendicular?: bool} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's (perpendicular too): a straight spine turns into a curve (#1111)",
|
||||
has_doc,
|
||||
spine_reshape_segment
|
||||
),
|
||||
@@ -238,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Gradient Mesh…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}",
|
||||
has_selection,
|
||||
mesh_create
|
||||
),
|
||||
@@ -402,16 +411,20 @@ const DEFAULT_DISTANCE: f64 = 10.0;
|
||||
|
||||
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
|
||||
let mode = str_param(p, "spacing");
|
||||
let value = num("value");
|
||||
let sp = match mode {
|
||||
Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor,
|
||||
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32),
|
||||
Some("steps") => match value {
|
||||
Some(v) => steps("value", v)?,
|
||||
None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?,
|
||||
},
|
||||
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)),
|
||||
Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))),
|
||||
None => {
|
||||
if let Some(n) = num("steps") {
|
||||
BlendSpacing::Steps(n as u32)
|
||||
steps("steps", n)?
|
||||
} else if let Some(d) = num("distance") {
|
||||
BlendSpacing::Distance(d)
|
||||
} else if bool_or(p, "smooth", false) {
|
||||
@@ -649,6 +662,37 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.convertAnchor";
|
||||
let index = index_param(p, "anchor", C)?;
|
||||
let smooth = match str_param(p, "to") {
|
||||
Some("smooth") => true,
|
||||
Some("corner") => false,
|
||||
_ => return Err(bad(C, "to must be smooth or corner")),
|
||||
};
|
||||
let target = match (p.get("x"), p.get("y")) {
|
||||
(None, None) => None,
|
||||
(Some(_), Some(_)) => Some(finite_point(p, C)?),
|
||||
_ => return Err(bad(C, "x and y must be given together")),
|
||||
};
|
||||
edit_spine(s, p, C, "Convert Anchor Point", |sp, _, _| {
|
||||
if smooth && target.is_none() {
|
||||
if index >= sp.anchors.len() {
|
||||
return Err(bad(C, "no such spine point"));
|
||||
}
|
||||
vectorcraft_tools::draw2::curvature_smooth(sp, index);
|
||||
return Ok(());
|
||||
}
|
||||
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
|
||||
match target {
|
||||
Some(q) if smooth && q.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, q),
|
||||
_ => a.retract(),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.reshapeSegment";
|
||||
let seg = index_param(p, "segment", C)?;
|
||||
@@ -657,7 +701,11 @@ fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
|
||||
return Err(bad(C, "t, dx and dy must be finite"));
|
||||
}
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
|
||||
let perpendicular = bool_or(p, "perpendicular", false);
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| {
|
||||
let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) };
|
||||
if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) }
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -955,14 +1003,15 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
|
||||
Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) })
|
||||
}
|
||||
|
||||
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
|
||||
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
|
||||
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
|
||||
let rows = f64_or(p, "rows", default.0 as f64);
|
||||
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
|
||||
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
|
||||
let cols = f64_or(p, cols_key, default.1 as f64);
|
||||
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
|
||||
return Err(bad(cmd, "rows and cols must be 1..50"));
|
||||
}
|
||||
Ok((rows as u32, cols as u32))
|
||||
Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32))
|
||||
}
|
||||
|
||||
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1299,7 +1348,7 @@ fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.setMeshPoint";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let index = f64_req(p, "index", C)? as usize;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let handle = handle_param(p, C)?;
|
||||
s.edit("Move Envelope Point", |d, _| {
|
||||
@@ -1344,18 +1393,33 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut index = None;
|
||||
let mut refused = None;
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let Some(path) = outline_of(&n) else { continue };
|
||||
let m = match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
let m = if let NodeKind::Image(im) = &n.kind {
|
||||
// An embedded image: the points take the picture's colours there.
|
||||
match image_mesh(d, im, rows, cols) {
|
||||
Ok(mut m) => {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
Some(m)
|
||||
}
|
||||
Err(why) => {
|
||||
refused.get_or_insert(why);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(path) = outline_of(&n) else { continue };
|
||||
match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
};
|
||||
let Some(mut m) = m else { continue };
|
||||
if let Some(q) = at {
|
||||
@@ -1371,7 +1435,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out.push(*id);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(C, "select a path or compound path"));
|
||||
return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image")));
|
||||
}
|
||||
sel.set(out.iter().copied());
|
||||
Ok((out, index))
|
||||
@@ -1383,6 +1447,31 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle
|
||||
/// (placed with the image's transform), each point taking the average colour and opacity of the
|
||||
/// area around it. Linked images are refused: the documentation allows embedded ones only.
|
||||
fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result<GradientMesh, &'static str> {
|
||||
if im.link.is_some() {
|
||||
return Err("a linked image can't become a gradient mesh: embed it first");
|
||||
}
|
||||
let xf = im.xf.as_coeffs();
|
||||
if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) {
|
||||
return Err("the image has no usable size or position");
|
||||
}
|
||||
let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?;
|
||||
let (w, h) = (im.width as f64, im.height as f64);
|
||||
let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h);
|
||||
if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) {
|
||||
return Err("the image has no usable size or position");
|
||||
}
|
||||
let cols_n = cols as usize + 1;
|
||||
let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::<Vec<_>>();
|
||||
Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| {
|
||||
let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]);
|
||||
(Color::rgb8(pr, pg, pb), pa as f32 / 255.0)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
|
||||
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
|
||||
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
@@ -1422,7 +1511,12 @@ fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
if i < 0.0 {
|
||||
return Err(bad(cmd, "index must be ≥ 0"));
|
||||
}
|
||||
Ok(i as usize)
|
||||
Ok(whole(i, key, cmd)? as usize)
|
||||
}
|
||||
|
||||
/// `x`, the value of `key`, when it is a whole number, and an error otherwise.
|
||||
fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
|
||||
if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) }
|
||||
}
|
||||
|
||||
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Object Mosaic…",
|
||||
["Object"],
|
||||
None,
|
||||
"{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
|
||||
"{columns=10, rows=10 (whole numbers, kept within 1..1000), width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
|
||||
has_image,
|
||||
object_mosaic
|
||||
),
|
||||
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Crop Image",
|
||||
["Object"],
|
||||
None,
|
||||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
|
||||
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim), warning? (without trim: the image couldn't be decoded, as object.rasterize says)}",
|
||||
has_image,
|
||||
crop_image
|
||||
),
|
||||
@@ -168,6 +168,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// The whole number `p` gives for `key`, written `4` or `4.0`, or `default` when `p` gives none or null. A value that
|
||||
/// is not a number gives the [`check_numbers`] error, and a fraction or a negative number gives
|
||||
/// "`{key}` must be a whole number".
|
||||
fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
|
||||
check_numbers(p, &[key], cmd)?;
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(default) };
|
||||
match (v.as_u64(), v.as_f64()) {
|
||||
(Some(n), _) => Ok(n),
|
||||
// A whole number past `u64::MAX` saturates to it.
|
||||
(None, Some(x)) if x >= 0.0 && x.fract() == 0.0 => Ok(x as u64),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be a whole number"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command's implementation without the enablement check or journaling.
|
||||
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
@@ -210,8 +224,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 symbols = d.symbol_boxes();
|
||||
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
|
||||
// ancestor directly inside the layer.
|
||||
// ancestor directly inside the layer. Symbol instances are measured by their art.
|
||||
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
|
||||
for id in &roots {
|
||||
let Some(layer) = d.layer_of(*id) else { continue };
|
||||
@@ -219,7 +234,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let anc = d.ancestry(*id).unwrap_or_default();
|
||||
let Some(pos) = anc.iter().position(|a| *a == layer) else { continue };
|
||||
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(b) = d.node(*id).and_then(|n| n.visual_bounds_with(&symbols)) else { continue };
|
||||
let Some((_, idx, _)) = d.position(top) else { continue };
|
||||
selected.entry(layer).or_default().push((idx, b));
|
||||
}
|
||||
@@ -239,7 +254,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
if under.is_empty() || roots.contains(&n.id) {
|
||||
continue;
|
||||
}
|
||||
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
if n.visual_bounds_with(&symbols).is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
|
||||
hits.insert(n.id);
|
||||
}
|
||||
}
|
||||
@@ -335,13 +350,13 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
let rx = bool_or(p, "reflectX", false);
|
||||
let ry = bool_or(p, "reflectY", false);
|
||||
let random = bool_or(p, "random", false);
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
|
||||
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let objects = super::object::transforms_objects(p);
|
||||
if sh == 0.0 || sv == 0.0 {
|
||||
return Err(bad("object.transformEach", "scale must be non-zero"));
|
||||
}
|
||||
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
|
||||
let mut rng = Rng(whole_or(p, "seed", 0x9E37_79B9_7F4A_7C15, "object.transformEach")?.max(1));
|
||||
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
|
||||
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
|
||||
// With no object to transform, or without a copy when both scales are 100% and there is no
|
||||
@@ -352,8 +367,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
|
||||
}
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
|
||||
for id in &targets {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
// Measure each object's geometric bounds first, with symbol instances measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let boxes: Vec<Option<Rect>> = targets.iter().map(|id| d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols))).collect();
|
||||
for (id, b) in targets.iter().zip(boxes) {
|
||||
let Some(b) = b else { continue };
|
||||
let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot);
|
||||
if random {
|
||||
k_sh = 100.0 + (sh - 100.0) * rng.next();
|
||||
@@ -408,38 +426,71 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`.
|
||||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an
|
||||
/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]).
|
||||
fn render_png(
|
||||
doc: &vectorcraft_doc::Document,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(Vec<u8>, u32, u32)> {
|
||||
) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> {
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||||
}
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let mut img = r.render_region_with(doc, region, scale, opts);
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning))
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.rasterize";
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let ppi = f64_or(p, "ppi", st.doc.raster_effects_ppi);
|
||||
/// The image Object › Rasterize makes of some art, before it goes in a document
|
||||
/// ([`rasterize_art`]).
|
||||
pub(crate) struct Rasterized {
|
||||
png: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Image pixels → document.
|
||||
xf: Affine,
|
||||
/// The art's outline, with Create Clipping Mask.
|
||||
clip: Option<(PathData, vectorcraft_geom::FillRule)>,
|
||||
/// The note that an image in the art couldn't be decoded ([`render_png`]).
|
||||
warning: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Rasterized {
|
||||
/// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's
|
||||
/// outline when it has one.
|
||||
pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node {
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png));
|
||||
let id = d.alloc_id();
|
||||
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() };
|
||||
let node = Node::new(id, NodeKind::Image(Box::new(image)));
|
||||
match self.clip {
|
||||
Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node),
|
||||
None => node,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
if !(1.0..=2400.0).contains(&ppi) {
|
||||
return Err(bad(C, "ppi must be between 1 and 2400"));
|
||||
return Err(bad(cmd, "ppi must be between 1 and 2400"));
|
||||
}
|
||||
// The document's raster effects settings, changed by the params.
|
||||
let mut look = st.doc.raster_effects.clone();
|
||||
let mut look = doc.raster_effects.clone();
|
||||
let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]);
|
||||
let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||||
for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) {
|
||||
match k.as_str() {
|
||||
"background" => background = fileio::background(v).map_err(|e| bad(C, e))?,
|
||||
"background" => background = fileio::background(v).map_err(|e| bad(cmd, e))?,
|
||||
"antiAlias" => {
|
||||
anti_alias = match v {
|
||||
Value::Bool(b) => {
|
||||
@@ -449,55 +500,74 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
AntiAlias::None
|
||||
}
|
||||
}
|
||||
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?,
|
||||
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(cmd, e))?,
|
||||
}
|
||||
}
|
||||
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?,
|
||||
"colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?,
|
||||
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?,
|
||||
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, cmd, k)?,
|
||||
"colorModel" => look.color_model = super::rasterfx::color_model(v, doc.color_mode, cmd)?,
|
||||
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(cmd, "clippingMask must be true or false"))?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() };
|
||||
let pad = look.add_around;
|
||||
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad);
|
||||
let scale = ppi / 72.0;
|
||||
// Snap to whole pixels.
|
||||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||||
let nodes: Vec<Node> = roots
|
||||
.iter()
|
||||
.filter_map(|id| st.doc.node(*id).cloned())
|
||||
let nodes: Vec<Node> = nodes
|
||||
.into_iter()
|
||||
.map(|mut n| {
|
||||
n.visible = true;
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
// Create Clipping Mask: the image is clipped to the art's outline.
|
||||
// What the art draws, its geometry effects applied.
|
||||
let b = nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::drawn_bounds(doc, n)));
|
||||
let b = b.ok_or_else(|| bad(cmd, "selection has no bounds"))?.inflate(pad, pad);
|
||||
let scale = ppi / 72.0;
|
||||
// Snap to whole pixels.
|
||||
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
|
||||
// Create Clipping Mask: the image is clipped to the art's outline with its geometry effects
|
||||
// applied, as on the canvas.
|
||||
let clip = look
|
||||
.clipping_mask
|
||||
.then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())))
|
||||
.then(|| {
|
||||
let outlines = nodes.iter().filter_map(|n| {
|
||||
let (bp, rule) = vectorcraft_render::drawn_outline(doc, n)?;
|
||||
let mut p = Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default());
|
||||
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
|
||||
*r = rule;
|
||||
}
|
||||
Some(Arc::new(p))
|
||||
});
|
||||
vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), outlines.collect()))
|
||||
})
|
||||
.flatten()
|
||||
.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 tmp = isolated_doc(doc, nodes);
|
||||
let (png, width, height, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Rasterized { png, width, height, xf, clip, warning })
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.rasterize";
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
|
||||
let image = rasterize_art(&st.doc, nodes, p, C)?;
|
||||
let (w, h, warning) = (image.width, image.height, image.warning);
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
}
|
||||
let id = s.edit("Rasterize", |d, sel| {
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut node =
|
||||
Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })));
|
||||
if let Some((path, rule)) = clip {
|
||||
node = super::rasterfx::clip_group(d, path, rule, node);
|
||||
}
|
||||
let node = image.into_node(d);
|
||||
let id = d.wrap(&roots, |_| node)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||||
let mut out = json!({ "id": id.0, "width": w, "height": h });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -524,8 +594,8 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
|
||||
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
|
||||
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||||
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
|
||||
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
|
||||
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
|
||||
let gray = bool_or(p, "gray", false);
|
||||
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
|
||||
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
|
||||
@@ -634,7 +704,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
bare.visible = true;
|
||||
let tmp = isolated_doc(&st.doc, vec![bare]);
|
||||
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
|
||||
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||||
let (png, w, h, warning) =
|
||||
render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||||
s.edit("Crop Image", |d, _| {
|
||||
let key = unique_key(d, "crop");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
@@ -650,7 +721,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "width": w, "height": h }))
|
||||
let mut out = json!({ "id": id.0, "width": w, "height": h });
|
||||
if let Some(w) = warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
|
||||
@@ -872,9 +947,11 @@ 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| {
|
||||
// Measure each object's geometric bounds first, with symbol instances measured by their art.
|
||||
let symbols = d.symbol_boxes();
|
||||
let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect();
|
||||
let mut converted = vec![];
|
||||
for id in &roots {
|
||||
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
|
||||
for (id, b) in boxes {
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
@@ -886,7 +963,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
show_center_mark: false,
|
||||
show_cross_hairs: false,
|
||||
});
|
||||
converted.push(*id);
|
||||
converted.push(id);
|
||||
}
|
||||
if converted.is_empty() {
|
||||
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
|
||||
@@ -916,8 +993,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let count = s.doc()?.doc.artboards.len();
|
||||
let n_ab = count.max(1);
|
||||
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
|
||||
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
|
||||
// Columns of a grid by row, rows of a grid by column.
|
||||
let lines = whole_or(p, "columns", 2, CMD)?.clamp(1, n_ab as u64) as usize;
|
||||
let (rows, cols) = match layout {
|
||||
L::GridByRow => (n_ab.div_ceil(lines), lines),
|
||||
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
|
||||
@@ -938,8 +1015,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
|
||||
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
|
||||
}
|
||||
// With moveArtwork, each top-level object moves with the artboard its center is on; locked and
|
||||
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
|
||||
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
|
||||
// hidden art moves only when prefs moveLockedWithArtboard is on.
|
||||
let symbols = d.symbol_boxes();
|
||||
let art: Vec<(NodeId, Vec2)> = if move_art {
|
||||
d.layers
|
||||
.iter()
|
||||
@@ -947,8 +1026,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.rides_with_artboard(locked_and_hidden))
|
||||
.filter_map(|n| {
|
||||
let c = n.geometric_bounds()?.center();
|
||||
let dl = *rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))?;
|
||||
let c = n.geometric_bounds_with(&symbols)?.center();
|
||||
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
|
||||
(dl != Vec2::ZERO).then_some((n.id, dl))
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -34,6 +34,7 @@ mod halftone;
|
||||
pub mod help;
|
||||
pub mod importmatches;
|
||||
pub(crate) mod inline;
|
||||
mod interactivity;
|
||||
mod intertwine;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
@@ -263,6 +264,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(interactivity::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
|
||||
@@ -114,19 +114,29 @@ fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D:
|
||||
pub struct DocSettings {
|
||||
/// The preset's name.
|
||||
pub name: String,
|
||||
/// Artboard size in points.
|
||||
/// Artboard size in points, more than 0 and up to `MAX_SIDE` (a saved or Recent preset's
|
||||
/// value outside that range is read as the nearer end, and one of 0 or less is read as 1).
|
||||
#[serde(deserialize_with = "de_side")]
|
||||
pub width: f64,
|
||||
#[serde(deserialize_with = "de_side")]
|
||||
pub height: f64,
|
||||
pub units: Unit,
|
||||
/// 1 to `MAX_ARTBOARDS` (a saved or Recent preset's value outside that range is read as the
|
||||
/// nearer end).
|
||||
#[serde(deserialize_with = "de_artboards")]
|
||||
pub artboards: u32,
|
||||
pub layout: ArtboardLayout,
|
||||
/// Columns of a grid layout.
|
||||
pub columns: u32,
|
||||
/// Space between artboards, points.
|
||||
/// Space between artboards, points: 0 to `MAX_SPACING` (a saved or Recent preset's value
|
||||
/// outside that range is read as the nearer end).
|
||||
#[serde(deserialize_with = "de_spacing")]
|
||||
pub spacing: f64,
|
||||
/// Change to Right-to-Left Layout: the first artboard at the right.
|
||||
pub right_to_left: bool,
|
||||
/// Top, bottom, left, right, points.
|
||||
/// Top, bottom, left, right, points: 0 to `MAX_BLEED` each (a saved or Recent preset's value
|
||||
/// outside that range is read as the nearer end).
|
||||
#[serde(deserialize_with = "de_bleed")]
|
||||
pub bleed: [f64; 4],
|
||||
pub background: Background,
|
||||
pub color_mode: ColorMode,
|
||||
@@ -348,6 +358,28 @@ pub fn color_mode_id(m: ColorMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's artboard side: up to [`MAX_SIDE`] points, and 1 when it is 0 or less.
|
||||
fn de_side<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
let v = f64::deserialize(d)?;
|
||||
Ok(if v > 0.0 { v.min(MAX_SIDE) } else { 1.0 })
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's artboard count, rounded and kept to 1 to [`MAX_ARTBOARDS`] as
|
||||
/// `file.new` reads its `artboards` param.
|
||||
fn de_artboards<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<u32, D::Error> {
|
||||
Ok(f64::deserialize(d)?.round().clamp(1.0, MAX_ARTBOARDS as f64) as u32)
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's artboard spacing, kept to 0 to [`MAX_SPACING`] points.
|
||||
fn de_spacing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
Ok(f64::deserialize(d)?.clamp(0.0, MAX_SPACING))
|
||||
}
|
||||
|
||||
/// A saved or Recent preset's bleed, each side kept to 0 to `MAX_BLEED` points.
|
||||
fn de_bleed<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<[f64; 4], D::Error> {
|
||||
Ok(vectorcraft_doc::setup::bleed_in_range(<[f64; 4]>::deserialize(d)?))
|
||||
}
|
||||
|
||||
/// Top-left corners for artboards of `sizes` from `origin`: `columns` per row, `spacing` apart,
|
||||
/// each column as wide and each row as tall as its largest artboard; `by_column` fills columns
|
||||
/// top to bottom first, `rtl` puts the first column at the right.
|
||||
|
||||
@@ -1061,11 +1061,16 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(i) = st.isolation.take() {
|
||||
super::distortcmds::finish_edit_text(st, i);
|
||||
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
|
||||
st.selection.set([target]);
|
||||
// A guide path isolated while Lock Guides is on isn't selected (#1534).
|
||||
if guides_locked {
|
||||
crate::deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
}
|
||||
st.revision += 1;
|
||||
ok()
|
||||
|
||||
@@ -298,14 +298,31 @@ fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Mod
|
||||
(mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model())
|
||||
}
|
||||
|
||||
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
|
||||
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
|
||||
/// are returned as given.
|
||||
fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
|
||||
match (c, model) {
|
||||
(Color::Rgb { .. }, Some(m)) => {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
cms.convert(&c, m, cms.settings().intent)
|
||||
}
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// A color given in `p` as the document stores it: an RGB color in a CMYK document (without
|
||||
/// `keepModel`) becomes CMYK through the color settings ([`stored_color`]).
|
||||
pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
|
||||
stored_color(new_color_model(s, p), c)
|
||||
}
|
||||
|
||||
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
|
||||
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]).
|
||||
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]),
|
||||
/// converted through the color settings.
|
||||
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||||
let model = new_color_model(s, p);
|
||||
let new_color = |c: Color| match (c, model) {
|
||||
(Color::Rgb { .. }, Some(m)) => c.in_model(m),
|
||||
_ => c,
|
||||
};
|
||||
let new_color = |c: Color| stored_color(model, c);
|
||||
if bool_or(p, "none", false) {
|
||||
return Ok(Some(Paint::None));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -276,7 +276,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"charAlign",
|
||||
"proportionalMetrics",
|
||||
"digits",
|
||||
"fontAxes",
|
||||
"burasagari",
|
||||
"romanHangingPunctuation",
|
||||
"composer",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
@@ -293,6 +295,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||
let char_align = super::textedit::char_align_param(p, C)?;
|
||||
let digits = super::textedit::digits_param(p, C)?;
|
||||
let font_axes = super::textedit::font_axes_param(p, C)?;
|
||||
let mojikumi = match p.get("mojikumi") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
@@ -378,6 +381,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("proportionalMetrics") {
|
||||
st.proportional_metrics = v;
|
||||
}
|
||||
if let Some(v) = &font_axes {
|
||||
super::textedit::change_font_axes(st, v);
|
||||
}
|
||||
};
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -439,6 +445,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = burasagari {
|
||||
para.burasagari = v;
|
||||
}
|
||||
if let Some(v) = flag("romanHangingPunctuation") {
|
||||
para.roman_hanging = v;
|
||||
}
|
||||
if let Some(v) = composer {
|
||||
para.composer = v;
|
||||
}
|
||||
|
||||
@@ -582,9 +582,9 @@ pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, ble
|
||||
n
|
||||
}
|
||||
|
||||
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art, or the
|
||||
/// outline of the stroke (as on the canvas) filled with the stroke's paint, opacity, blend mode
|
||||
/// and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped
|
||||
/// The art stroke `st` of a path (`path`, `rule`) paints, as fills: its brush art (with the
|
||||
/// stroke's opacity, blend mode and raster effects), or the outline of the stroke (as on the canvas)
|
||||
/// filled with the stroke's paint, opacity, blend mode and effects (a gradient along or across the stroke: gradient meshes in a clip group shaped
|
||||
/// like the outline). `None` when it paints nothing.
|
||||
pub(crate) fn outlined_stroke(
|
||||
d: &mut Document,
|
||||
@@ -601,7 +601,15 @@ pub(crate) fn outlined_stroke(
|
||||
_ => Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
let art = d.reid(&art);
|
||||
return Some(with_transparency(d, art, st.opacity, st.blend));
|
||||
let mut art = with_transparency(d, art, st.opacity, st.blend);
|
||||
let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect();
|
||||
if !raster.is_empty() {
|
||||
if !art.appearance.effects.is_empty() {
|
||||
art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
|
||||
}
|
||||
art.appearance.effects = raster;
|
||||
}
|
||||
return Some(art);
|
||||
}
|
||||
let outline = vectorcraft_render::effects::stroke::outline_region(path, rule, st);
|
||||
if outline.is_empty() {
|
||||
@@ -673,12 +681,13 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>
|
||||
f
|
||||
}
|
||||
|
||||
/// What stands for object `l` once its appearance became `parts` (paint order): the one part or a
|
||||
/// group of them, with `l`'s transparency, name, opacity mask and effects. `None` without parts.
|
||||
/// What stands for object `l` once its appearance became `parts` (paint order): the one part (in a
|
||||
/// group when it has effects of its own) or a group of them, with `l`'s transparency, name, opacity
|
||||
/// mask and effects. `None` without parts.
|
||||
pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Option<Node> {
|
||||
let root = match parts.len() {
|
||||
0 => return None,
|
||||
1 => parts.remove(0),
|
||||
1 if parts[0].appearance.effects.is_empty() => parts.remove(0),
|
||||
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
let mut root = with_transparency(d, root, l.opacity, l.blend);
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Pattern"],
|
||||
None,
|
||||
"{ids?, name?, tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
|
||||
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
|
||||
has_selection,
|
||||
pattern_make
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save a Copy",
|
||||
[],
|
||||
None,
|
||||
"{name?} while editing, save the current tile as a new pattern swatch → {name}",
|
||||
"{name? (trimmed; blank: <pattern> copy)} while editing, save the current tile as a new pattern swatch → {name}",
|
||||
in_pattern_edit,
|
||||
pattern_save_copy
|
||||
),
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pattern Options",
|
||||
["Window", "Pattern Options"],
|
||||
None,
|
||||
"{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
|
||||
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
|
||||
has_doc,
|
||||
pattern_options
|
||||
),
|
||||
@@ -294,7 +294,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "width/height must be > 0"));
|
||||
}
|
||||
let edit = bool_or(p, "edit", true);
|
||||
let wanted = str_param(p, "name").map(str::to_string);
|
||||
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let name = s.edit("Make Pattern", |d, sel| {
|
||||
let name = match &wanted {
|
||||
Some(n) => unique_pattern_name(d, n),
|
||||
@@ -388,12 +388,12 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let wanted = str_param(p, "name").map(str::to_string);
|
||||
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let name = s.edit("Save a Copy", |d, _| {
|
||||
let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
|
||||
let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?;
|
||||
let art: Vec<Arc<Node>> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default();
|
||||
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern)));
|
||||
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim())));
|
||||
def.name = name.clone();
|
||||
def.art = copies(d, &art).into_iter().map(Arc::new).collect();
|
||||
if def.size_tile_to_art {
|
||||
@@ -419,7 +419,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("{k} must be > 0")));
|
||||
}
|
||||
}
|
||||
let new_name = str_param(p, "newName").map(str::to_string).filter(|n| !n.is_empty() && *n != name);
|
||||
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
|
||||
if let Some(n) = &new_name
|
||||
&& s.doc()?.doc.pattern(n).is_some()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,977 @@
|
||||
//! Placing a Word document (`.docx`): its text set as area type with the character and paragraph
|
||||
//! formatting VectorCraft has, read as ECMA-376 Part 1 describes WordprocessingML
|
||||
//! (<https://ecma-international.org/publications-and-standards/standards/ecma-376/>), in its
|
||||
//! Transitional and Strict namespaces.
|
||||
//!
|
||||
//! - The package is a ZIP archive ([`fileio::zip`]). `_rels/.rels` names the main document part
|
||||
//! (else `word/document.xml`), and that part's relationships name its styles and theme parts
|
||||
//! (else `styles.xml` and `theme/theme1.xml` next to it).
|
||||
//! - Text: each paragraph (`w:p`) is a paragraph, and so is each table row, whose cells are
|
||||
//! separated by tabs. A text-wrapping break (`w:br`) and a carriage return (`w:cr`) start a new
|
||||
//! paragraph; page and column breaks are skipped. `w:tab` and `w:ptab` are tabs, and
|
||||
//! `w:noBreakHyphen` and `w:softHyphen` are their hyphens. Text in insertions, moves, hyperlinks,
|
||||
//! smart tags, content controls, custom XML and simple fields is read, and of a complex field,
|
||||
//! its result.
|
||||
//! - Character formatting: the font (`w:rFonts`: `w:asciiTheme`, `w:ascii`, `w:hAnsiTheme`, then
|
||||
//! `w:hAnsi`; a theme font is the theme's major or minor Latin font), the size, bold, italic,
|
||||
//! underline (every style but `none`), strikethrough and double strikethrough, all caps, small
|
||||
//! caps, superscript and subscript (`w:vertAlign`), raised and lowered text (`w:position`) and
|
||||
//! the color.
|
||||
//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`,
|
||||
//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and
|
||||
//! hanging indents, space before and after, and direction (`w:bidi`).
|
||||
//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph
|
||||
//! style) and the styles it is based on, then the run's character style (else the default
|
||||
//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps,
|
||||
//! small caps, strikethrough and hidden text are toggle properties (§17.7.3): a style that turns
|
||||
//! one on reverses the state the levels before it give, and direct formatting sets it.
|
||||
//!
|
||||
//! Hidden text (`w:vanish`), deleted text, field instructions, drawings, pictures, objects, symbol
|
||||
//! characters (`w:sym`), note and comment references, headers, footers, notes, comments and list
|
||||
//! numbers are left out. A package with more than [`MAX_FILES`] files, a part that unpacks to more
|
||||
//! than [`MAX_PART`] bytes or nests elements more than [`MAX_NESTING`] deep, and a document with
|
||||
//! more than [`MAX_PARAGRAPHS`] paragraphs, [`MAX_RUNS`] runs of one formatting or
|
||||
//! [`text::MAX_TEXT`] bytes of text are refused. Tables, content controls and other containers
|
||||
//! nested more than [`MAX_DEPTH`] deep are skipped, and so are fields nested deeper.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use roxmltree::Node;
|
||||
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
|
||||
|
||||
use super::text::{self, Story};
|
||||
use super::*;
|
||||
use crate::cmd::fileio::zip::{self, Archive, ReadError};
|
||||
|
||||
/// Word documents as `file.place` reports them (not a format `document.open` reads).
|
||||
pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
id: "docx",
|
||||
label: "Word",
|
||||
extensions: fileio::DOCX_EXTS,
|
||||
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
read: true,
|
||||
write: false,
|
||||
raster: false,
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The most files a Word document's package may hold.
|
||||
const MAX_FILES: usize = 10_000;
|
||||
/// The most bytes a part read from the package may unpack to.
|
||||
const MAX_PART: usize = 16 << 20;
|
||||
/// The most XML nodes a part read from the package may have.
|
||||
const MAX_NODES: u32 = 2_000_000;
|
||||
/// The deepest nesting of XML elements a part read from the package may have.
|
||||
const MAX_NESTING: usize = 256;
|
||||
/// The deepest nesting of tables, content controls and other containers read; deeper ones are
|
||||
/// skipped, and so are fields nested deeper.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// The most styles read from the styles part.
|
||||
const MAX_STYLES: usize = 4096;
|
||||
/// The most styles one style's `w:basedOn` chain takes in.
|
||||
const MAX_BASED_ON: usize = 32;
|
||||
/// The most fonts the document's formatting names.
|
||||
const MAX_FONTS: usize = 4096;
|
||||
/// The most bytes of a font name kept.
|
||||
const MAX_NAME: usize = 256;
|
||||
/// The most paragraphs placed type takes from one Word document.
|
||||
const MAX_PARAGRAPHS: usize = 100_000;
|
||||
/// The most runs of one character formatting placed type takes from one Word document.
|
||||
const MAX_RUNS: usize = 100_000;
|
||||
|
||||
/// WordprocessingML's namespaces: Transitional, then Strict.
|
||||
const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
|
||||
|
||||
/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds
|
||||
/// `word/document.xml`?
|
||||
pub(super) fn is_docx(name: &str, bytes: &[u8]) -> bool {
|
||||
zip::is_zip(bytes)
|
||||
&& (fileio::DOCX_EXTS.contains(&fileio::extension(name).as_str())
|
||||
|| Archive::open(bytes, MAX_FILES).is_ok_and(|z| z.contains("word/document.xml")))
|
||||
}
|
||||
|
||||
/// The text of a Word document's `bytes` (a ZIP package) with its character and paragraph
|
||||
/// formatting.
|
||||
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
|
||||
let zip = Archive::open(bytes, MAX_FILES).map_err(|e| package_error(cmd, e))?;
|
||||
let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into());
|
||||
let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) };
|
||||
let doc = parse(&xml, &main, cmd)?;
|
||||
if local(doc.root_element()) != Some("document") {
|
||||
return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document"));
|
||||
}
|
||||
let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default();
|
||||
let mut r = Reader::default();
|
||||
let theme = relationship(&zip, &main, "theme", cmd)?.unwrap_or_else(|| format!("{folder}theme/theme1.xml"));
|
||||
if let Some(xml) = part(&zip, &theme, cmd)? {
|
||||
r.theme_fonts(&parse(&xml, &theme, cmd)?);
|
||||
}
|
||||
let styles = relationship(&zip, &main, "styles", cmd)?.unwrap_or_else(|| format!("{folder}styles.xml"));
|
||||
if let Some(xml) = part(&zip, &styles, cmd)? {
|
||||
r.styles(&parse(&xml, &styles, cmd)?);
|
||||
}
|
||||
if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) {
|
||||
r.block(body, 0);
|
||||
}
|
||||
r.finish(cmd)
|
||||
}
|
||||
|
||||
/// The error for a package (or a part of it) the ZIP reader can't read.
|
||||
fn package_error(cmd: &str, e: ReadError) -> EngineError {
|
||||
bad(
|
||||
cmd,
|
||||
match e {
|
||||
ReadError::NotZip => "not a Word document: it isn't a ZIP package".to_string(),
|
||||
ReadError::TooManyFiles => format!("the Word document's package has more than {MAX_FILES} files"),
|
||||
ReadError::Zip64 => "the Word document's package uses ZIP64 records, which VectorCraft doesn't read".to_string(),
|
||||
ReadError::Encrypted(name) => format!("`{name}` in the Word document is encrypted"),
|
||||
ReadError::Method(name, m) => format!("`{name}` in the Word document uses compression method {m}, which VectorCraft doesn't read"),
|
||||
ReadError::TooLarge(name) => format!("`{name}` in the Word document is too large to place (at most {} MB unpacked)", MAX_PART >> 20),
|
||||
ReadError::Damaged(name) if name.is_empty() => "the Word document's package is damaged".to_string(),
|
||||
ReadError::Damaged(name) => format!("the Word document's package is damaged: `{name}` can't be read"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The part `name` of the package as text (UTF-8, or UTF-16 with a byte-order mark); None: the
|
||||
/// package has no such part.
|
||||
fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> {
|
||||
let Some(bytes) = zip.read(name, MAX_PART).map_err(|e| package_error(cmd, e))? else { return Ok(None) };
|
||||
let utf16 = |rest: &[u8], le: bool| -> String {
|
||||
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
|
||||
};
|
||||
let text = match &bytes[..] {
|
||||
[0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).map(str::to_string),
|
||||
[0xFF, 0xFE, rest @ ..] => Ok(utf16(rest, true)),
|
||||
[0xFE, 0xFF, rest @ ..] => Ok(utf16(rest, false)),
|
||||
_ => std::str::from_utf8(&bytes).map(str::to_string),
|
||||
};
|
||||
text.map(Some).map_err(|_| bad(cmd, format!("`{name}` in the Word document is not UTF-8 or UTF-16 text")))
|
||||
}
|
||||
|
||||
/// The XML of the part `name`.
|
||||
fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> {
|
||||
// roxmltree parses nested elements recursively.
|
||||
if nesting(xml) > MAX_NESTING {
|
||||
return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")));
|
||||
}
|
||||
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() };
|
||||
roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))
|
||||
}
|
||||
|
||||
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
|
||||
/// CDATA sections, processing instructions, declarations and quoted attribute values.
|
||||
fn nesting(xml: &str) -> usize {
|
||||
let b = xml.as_bytes();
|
||||
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
|
||||
// The index just past the first `end` at or after `from` (the end of the text if none).
|
||||
let past = |from: usize, end: &[u8]| {
|
||||
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
|
||||
};
|
||||
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
|
||||
let start = i + at;
|
||||
let rest = b.get(start..).unwrap_or_default();
|
||||
if rest.starts_with(b"<!--") {
|
||||
i = past(start + 4, b"-->");
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
i = past(start + 9, b"]]>");
|
||||
} else if rest.starts_with(b"<?") {
|
||||
i = past(start + 2, b"?>");
|
||||
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
|
||||
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
|
||||
i = past(start + 2, b">");
|
||||
} else {
|
||||
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
|
||||
let mut j = start + 1;
|
||||
let mut quote = None;
|
||||
while let Some(&c) = b.get(j) {
|
||||
match (quote, c) {
|
||||
(None, b'"' | b'\'') => quote = Some(c),
|
||||
(Some(q), _) if c == q => quote = None,
|
||||
(None, b'>') => break,
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if b.get(j.saturating_sub(1)) != Some(&b'/') {
|
||||
depth += 1;
|
||||
most = most.max(depth);
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
most
|
||||
}
|
||||
|
||||
/// The part that the first relationship of part `source` (`""`: the package) whose type ends in
|
||||
/// `/{kind}` points to; None when the package has no relationships part for `source` or it names
|
||||
/// no such part.
|
||||
fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Option<String>> {
|
||||
let (folder, file) = source.rfind('/').map_or(("", source), |i| (source.get(..=i).unwrap_or_default(), source.get(i + 1..).unwrap_or_default()));
|
||||
let rels = format!("{folder}_rels/{file}.rels");
|
||||
let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) };
|
||||
let doc = parse(&xml, &rels, cmd)?;
|
||||
let target = doc
|
||||
.root_element()
|
||||
.children()
|
||||
.filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External"))
|
||||
.find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind)))
|
||||
.and_then(|n| n.attribute("Target"));
|
||||
Ok(target.map(|t| resolve(folder, t)))
|
||||
}
|
||||
|
||||
/// The part name `target` gives, relative to the folder `folder` (`""` or ending in `/`).
|
||||
fn resolve(folder: &str, target: &str) -> String {
|
||||
let joined = match target.strip_prefix('/') {
|
||||
Some(absolute) => absolute.to_string(),
|
||||
None => format!("{folder}{target}"),
|
||||
};
|
||||
let mut parts: Vec<&str> = vec![];
|
||||
for segment in joined.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop();
|
||||
}
|
||||
s => parts.push(s),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// `n`'s local name when it is a WordprocessingML element.
|
||||
fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> {
|
||||
(n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name())
|
||||
}
|
||||
|
||||
/// `n`'s first WordprocessingML child element named `name`.
|
||||
fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> {
|
||||
n.children().find(|c| local(*c) == Some(name))
|
||||
}
|
||||
|
||||
/// `n`'s WordprocessingML attribute `name`.
|
||||
fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> {
|
||||
n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value())
|
||||
}
|
||||
|
||||
/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`.
|
||||
fn on(n: Node) -> bool {
|
||||
!matches!(attr(n, "val"), Some("false" | "0" | "off"))
|
||||
}
|
||||
|
||||
/// A measure `v` in units of `1 / per_pt` point: a number in those units, or a number with a unit
|
||||
/// (`mm`, `cm`, `in`, `pt`, `pc`, `pi`).
|
||||
fn measure(v: &str, per_pt: f64) -> Option<f64> {
|
||||
const UNITS: [(&str, f64); 6] = [("mm", 72.0 / 25.4), ("cm", 72.0 / 2.54), ("in", 72.0), ("pt", 1.0), ("pc", 12.0), ("pi", 12.0)];
|
||||
let v = v.trim();
|
||||
let n = match UNITS.iter().find_map(|(unit, pt)| v.strip_suffix(unit).map(|n| (n, pt))) {
|
||||
Some((n, pt)) => n.trim().parse::<f64>().ok()? * pt * per_pt,
|
||||
None => v.parse::<f64>().ok()?,
|
||||
};
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
/// Twentieths of a point.
|
||||
fn twips(n: Node, name: &str) -> Option<f64> {
|
||||
attr(n, name).and_then(|v| measure(v, 20.0))
|
||||
}
|
||||
|
||||
/// Half points.
|
||||
fn half_points(n: Node) -> Option<f64> {
|
||||
attr(n, "val").and_then(|v| measure(v, 2.0))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Script {
|
||||
#[default]
|
||||
Normal,
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// The run properties one level of the style hierarchy sets (None: it doesn't set that one).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct RunProps {
|
||||
/// The font's number in [`Reader::fonts`].
|
||||
font: Option<usize>,
|
||||
half_points: Option<f64>,
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
caps: Option<bool>,
|
||||
small_caps: Option<bool>,
|
||||
strike: Option<bool>,
|
||||
dstrike: Option<bool>,
|
||||
hidden: Option<bool>,
|
||||
underline: Option<bool>,
|
||||
script: Option<Script>,
|
||||
/// Raised (positive) or lowered text in half points.
|
||||
shift: Option<f64>,
|
||||
/// Some(None): automatic (the default color).
|
||||
color: Option<Option<[u8; 3]>>,
|
||||
}
|
||||
|
||||
/// Character formatting.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Chars {
|
||||
font: Option<usize>,
|
||||
/// None: VectorCraft's default size.
|
||||
half_points: Option<f64>,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
caps: bool,
|
||||
small_caps: bool,
|
||||
strike: bool,
|
||||
dstrike: bool,
|
||||
hidden: bool,
|
||||
underline: bool,
|
||||
script: Script,
|
||||
shift: f64,
|
||||
color: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
impl Chars {
|
||||
/// `p` applied over this formatting: as a style's (a toggle property turned on reverses its
|
||||
/// state, turned off leaves it) or as direct formatting (it sets each property).
|
||||
fn apply(&mut self, p: &RunProps, style: bool) {
|
||||
let toggle = |state: &mut bool, v: Option<bool>| match v {
|
||||
Some(true) if style => *state = !*state,
|
||||
Some(v) if !style => *state = v,
|
||||
_ => {}
|
||||
};
|
||||
toggle(&mut self.bold, p.bold);
|
||||
toggle(&mut self.italic, p.italic);
|
||||
toggle(&mut self.caps, p.caps);
|
||||
toggle(&mut self.small_caps, p.small_caps);
|
||||
toggle(&mut self.strike, p.strike);
|
||||
toggle(&mut self.hidden, p.hidden);
|
||||
self.font = p.font.or(self.font);
|
||||
self.half_points = p.half_points.or(self.half_points);
|
||||
self.dstrike = p.dstrike.unwrap_or(self.dstrike);
|
||||
self.underline = p.underline.unwrap_or(self.underline);
|
||||
self.script = p.script.unwrap_or(self.script);
|
||||
self.shift = p.shift.unwrap_or(self.shift);
|
||||
self.color = p.color.unwrap_or(self.color);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Align {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
/// Justified, the last line at the start (the right of a right-to-left paragraph).
|
||||
Both,
|
||||
/// Justified, the last line too.
|
||||
Distribute,
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
/// The paragraph properties one level of the style hierarchy sets; lengths in twips.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ParaProps {
|
||||
align: Option<Align>,
|
||||
/// The indent at the paragraph's start (the left of a left-to-right paragraph).
|
||||
start: Option<f64>,
|
||||
/// The indent at the paragraph's end.
|
||||
end: Option<f64>,
|
||||
first: Option<f64>,
|
||||
before: Option<f64>,
|
||||
after: Option<f64>,
|
||||
bidi: Option<bool>,
|
||||
}
|
||||
|
||||
/// Paragraph formatting; lengths in twips.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Para {
|
||||
align: Align,
|
||||
start: f64,
|
||||
end: f64,
|
||||
first: f64,
|
||||
before: f64,
|
||||
after: f64,
|
||||
rtl: Option<bool>,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
fn apply(&mut self, p: &ParaProps) {
|
||||
self.align = p.align.unwrap_or(self.align);
|
||||
self.start = p.start.unwrap_or(self.start);
|
||||
self.end = p.end.unwrap_or(self.end);
|
||||
self.first = p.first.unwrap_or(self.first);
|
||||
self.before = p.before.unwrap_or(self.before);
|
||||
self.after = p.after.unwrap_or(self.after);
|
||||
self.rtl = p.bidi.or(self.rtl);
|
||||
}
|
||||
}
|
||||
|
||||
/// A style of the styles part.
|
||||
struct Style {
|
||||
based_on: Option<String>,
|
||||
run: RunProps,
|
||||
para: ParaProps,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reader {
|
||||
/// The theme's major and minor Latin fonts.
|
||||
theme: [Option<String>; 2],
|
||||
fonts: Vec<String>,
|
||||
font_numbers: HashMap<String, usize>,
|
||||
styles: HashMap<String, Style>,
|
||||
default_run: RunProps,
|
||||
default_para: ParaProps,
|
||||
/// The default paragraph style's id.
|
||||
default_style: Option<String>,
|
||||
/// The default character style's id.
|
||||
default_char_style: Option<String>,
|
||||
/// The paragraph being read: its formatting, and its run formatting before character styles
|
||||
/// and direct formatting.
|
||||
para: Para,
|
||||
base: Chars,
|
||||
/// A paragraph that has ended (its formatting and its mark's): its break goes before the
|
||||
/// next text.
|
||||
pending: Option<(Para, Chars)>,
|
||||
/// The last cell's paragraph, which its row's break ends.
|
||||
cell_end: Option<(Para, Chars)>,
|
||||
/// A cell just ended: a tab goes before the next character of its row.
|
||||
cell_tab: bool,
|
||||
/// The complex fields open: true while their instructions are being read.
|
||||
fields: Vec<bool>,
|
||||
/// Fields begun past [`MAX_DEPTH`] and not yet ended.
|
||||
field_overflow: usize,
|
||||
/// The text, in runs of one character formatting; paragraphs end with `\n`.
|
||||
spans: Vec<(String, Chars)>,
|
||||
/// The bytes of text in `spans`.
|
||||
len: usize,
|
||||
/// The formatting of each paragraph that has ended.
|
||||
paras: Vec<Para>,
|
||||
/// Past [`MAX_PARAGRAPHS`], [`MAX_RUNS`] or [`text::MAX_TEXT`].
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
/// The major and minor fonts of a theme part's font scheme.
|
||||
fn theme_fonts(&mut self, doc: &roxmltree::Document) {
|
||||
for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) {
|
||||
*slot = doc
|
||||
.descendants()
|
||||
.find(|n| n.tag_name().name() == scheme)
|
||||
.and_then(|n| n.children().find(|c| c.tag_name().name() == "latin"))
|
||||
.and_then(|n| n.attribute("typeface"))
|
||||
.map(font_name)
|
||||
.filter(|f| !f.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The document defaults and the styles of the styles part.
|
||||
fn styles(&mut self, doc: &roxmltree::Document) {
|
||||
for n in doc.root_element().children() {
|
||||
match local(n) {
|
||||
Some("docDefaults") => {
|
||||
let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner));
|
||||
if let Some(rpr) = props("rPrDefault", "rPr") {
|
||||
self.default_run = self.run_props(rpr);
|
||||
}
|
||||
if let Some(ppr) = props("pPrDefault", "pPr") {
|
||||
self.default_para = para_props(ppr);
|
||||
}
|
||||
}
|
||||
Some("style") if self.styles.len() < MAX_STYLES => {
|
||||
let Some(id) = attr(n, "styleId") else { continue };
|
||||
let default = match attr(n, "type") {
|
||||
Some("paragraph") => Some(&mut self.default_style),
|
||||
Some("character") => Some(&mut self.default_char_style),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(default) = default.filter(|_| attr(n, "default").is_some_and(|v| matches!(v, "1" | "true" | "on"))) {
|
||||
default.get_or_insert_with(|| id.to_string());
|
||||
}
|
||||
let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default();
|
||||
let para = child(n, "pPr").map(para_props).unwrap_or_default();
|
||||
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string);
|
||||
self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Style `id` and the styles it is based on, the first of the chain first.
|
||||
fn chain(&self, id: Option<&str>) -> Vec<&Style> {
|
||||
let mut ids: Vec<&str> = vec![];
|
||||
let mut chain = vec![];
|
||||
let mut next = id;
|
||||
while let Some(id) = next {
|
||||
if ids.contains(&id) || ids.len() >= MAX_BASED_ON {
|
||||
break;
|
||||
}
|
||||
let Some(style) = self.styles.get(id) else { break };
|
||||
ids.push(id);
|
||||
chain.push(style);
|
||||
next = style.based_on.as_deref();
|
||||
}
|
||||
chain.reverse();
|
||||
chain
|
||||
}
|
||||
|
||||
/// The number of the font a `w:rFonts` element names.
|
||||
fn font(&mut self, n: Node) -> Option<usize> {
|
||||
let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref);
|
||||
let name = attr(n, "asciiTheme")
|
||||
.and_then(theme)
|
||||
.or_else(|| attr(n, "ascii"))
|
||||
.or_else(|| attr(n, "hAnsiTheme").and_then(theme))
|
||||
.or_else(|| attr(n, "hAnsi"))
|
||||
.map(font_name)?;
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(&i) = self.font_numbers.get(&name) {
|
||||
return Some(i);
|
||||
}
|
||||
if self.fonts.len() >= MAX_FONTS {
|
||||
return None;
|
||||
}
|
||||
self.fonts.push(name.clone());
|
||||
self.font_numbers.insert(name, self.fonts.len() - 1);
|
||||
Some(self.fonts.len() - 1)
|
||||
}
|
||||
|
||||
/// The run properties of a `w:rPr` element.
|
||||
fn run_props(&mut self, rpr: Node) -> RunProps {
|
||||
let mut p = RunProps::default();
|
||||
for n in rpr.children() {
|
||||
match local(n) {
|
||||
Some("rFonts") => p.font = self.font(n),
|
||||
Some("b") => p.bold = Some(on(n)),
|
||||
Some("i") => p.italic = Some(on(n)),
|
||||
Some("caps") => p.caps = Some(on(n)),
|
||||
Some("smallCaps") => p.small_caps = Some(on(n)),
|
||||
Some("strike") => p.strike = Some(on(n)),
|
||||
Some("dstrike") => p.dstrike = Some(on(n)),
|
||||
Some("vanish") => p.hidden = Some(on(n)),
|
||||
Some("u") => p.underline = Some(attr(n, "val") != Some("none")),
|
||||
Some("vertAlign") => {
|
||||
p.script = match attr(n, "val") {
|
||||
Some("superscript") => Some(Script::Super),
|
||||
Some("subscript") => Some(Script::Sub),
|
||||
Some("baseline") => Some(Script::Normal),
|
||||
_ => p.script,
|
||||
}
|
||||
}
|
||||
Some("position") => p.shift = half_points(n).or(p.shift),
|
||||
Some("sz") => p.half_points = half_points(n).filter(|v| *v > 0.0).or(p.half_points),
|
||||
Some("color") => {
|
||||
let hex = |v: &str| -> Option<[u8; 3]> {
|
||||
let v = v.trim();
|
||||
if v.len() != 6 || !v.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| v.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
Some([byte(0)?, byte(2)?, byte(4)?])
|
||||
};
|
||||
p.color = match attr(n, "val") {
|
||||
Some("auto") => Some(None),
|
||||
Some(v) => hex(v).map(Some).or(p.color),
|
||||
None => p.color,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// The paragraphs, tables and containers of them that are `n`'s children.
|
||||
fn block(&mut self, n: Node, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in n.children() {
|
||||
if self.full {
|
||||
return;
|
||||
}
|
||||
match local(c) {
|
||||
Some("p") => self.paragraph(c, depth),
|
||||
Some("tbl") => self.table(c, depth),
|
||||
Some("sdt") => {
|
||||
if let Some(content) = child(c, "sdtContent") {
|
||||
self.block(content, depth + 1);
|
||||
}
|
||||
}
|
||||
Some("customXml" | "ins" | "moveTo") => self.block(c, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A table: each row a paragraph, its cells separated by tabs.
|
||||
fn table(&mut self, tbl: Node, depth: usize) {
|
||||
for row in items(tbl, "tr", depth) {
|
||||
for cell in items(row, "tc", depth) {
|
||||
self.flush();
|
||||
self.block(cell, depth + 1);
|
||||
// The cell's last paragraph goes on in the next cell, after a tab.
|
||||
let end = self.pending.take();
|
||||
if self.cell_tab
|
||||
&& let Some((_, mark)) = end
|
||||
{
|
||||
self.push('\t', mark);
|
||||
}
|
||||
self.cell_end = end.or(self.cell_end);
|
||||
self.cell_tab = true;
|
||||
}
|
||||
self.cell_tab = false;
|
||||
self.pending = self.cell_end.take();
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph.
|
||||
fn paragraph(&mut self, p: Node, depth: usize) {
|
||||
self.flush();
|
||||
let ppr = child(p, "pPr");
|
||||
let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone());
|
||||
let mut para = Para::default();
|
||||
para.apply(&self.default_para);
|
||||
let mut base = Chars::default();
|
||||
base.apply(&self.default_run, true);
|
||||
for s in self.chain(style.as_deref()) {
|
||||
para.apply(&s.para);
|
||||
base.apply(&s.run, true);
|
||||
}
|
||||
if let Some(n) = ppr {
|
||||
para.apply(¶_props(n));
|
||||
}
|
||||
(self.para, self.base) = (para, base);
|
||||
self.inline(p, depth + 1);
|
||||
// The paragraph mark's run properties are direct formatting over the paragraph style's.
|
||||
let mut mark = base;
|
||||
if let Some(rpr) = ppr.and_then(|n| child(n, "rPr")) {
|
||||
let props = self.run_props(rpr);
|
||||
mark.apply(&props, false);
|
||||
}
|
||||
// A line break at the paragraph's end is written before the paragraph ends, and a hidden
|
||||
// paragraph mark joins the paragraph to the next.
|
||||
if !mark.hidden {
|
||||
self.flush();
|
||||
self.pending = Some((para, mark));
|
||||
}
|
||||
}
|
||||
|
||||
/// The runs of a paragraph, and the containers of runs in it.
|
||||
fn inline(&mut self, n: Node, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in n.children() {
|
||||
match local(c) {
|
||||
Some("r") => self.run(c),
|
||||
Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1),
|
||||
Some("sdt") => {
|
||||
if let Some(content) = child(c, "sdtContent") {
|
||||
self.inline(content, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run.
|
||||
fn run(&mut self, r: Node) {
|
||||
let rpr = child(r, "rPr");
|
||||
let mut chars = self.base;
|
||||
let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref());
|
||||
for s in self.chain(style) {
|
||||
chars.apply(&s.run, true);
|
||||
}
|
||||
if let Some(n) = rpr {
|
||||
let props = self.run_props(n);
|
||||
chars.apply(&props, false);
|
||||
}
|
||||
for c in r.children() {
|
||||
match local(c) {
|
||||
Some("t") => {
|
||||
// Comments and processing instructions may split the text.
|
||||
for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) {
|
||||
self.put(ch, chars);
|
||||
}
|
||||
}
|
||||
Some("tab" | "ptab") => self.put('\t', chars),
|
||||
Some("noBreakHyphen") => self.put('\u{2011}', chars),
|
||||
Some("softHyphen") => self.put('\u{AD}', chars),
|
||||
Some("br") if matches!(attr(c, "type"), None | Some("textWrapping")) => self.line_break(chars),
|
||||
Some("cr") => self.line_break(chars),
|
||||
Some("fldChar") => match attr(c, "fldCharType") {
|
||||
Some("begin") if self.fields.len() < MAX_DEPTH => self.fields.push(true),
|
||||
Some("begin") => self.field_overflow += 1,
|
||||
Some("separate") if self.field_overflow == 0 => {
|
||||
if let Some(instructions) = self.fields.last_mut() {
|
||||
*instructions = false;
|
||||
}
|
||||
}
|
||||
Some("end") if self.field_overflow > 0 => self.field_overflow -= 1,
|
||||
Some("end") => {
|
||||
self.fields.pop();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text is not read: hidden, or a field's instructions.
|
||||
fn skipped(&self, chars: &Chars) -> bool {
|
||||
chars.hidden || self.field_overflow > 0 || self.fields.contains(&true)
|
||||
}
|
||||
|
||||
/// Character `c` of the document's text in formatting `chars`.
|
||||
fn put(&mut self, c: char, chars: Chars) {
|
||||
if self.skipped(&chars) {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
if std::mem::take(&mut self.cell_tab) {
|
||||
self.push('\t', chars);
|
||||
}
|
||||
self.push(c, chars);
|
||||
}
|
||||
|
||||
/// A line break: the rest of the paragraph is a new paragraph in the same formatting.
|
||||
fn line_break(&mut self, chars: Chars) {
|
||||
if self.skipped(&chars) {
|
||||
return;
|
||||
}
|
||||
self.flush();
|
||||
self.pending = Some((self.para, chars));
|
||||
}
|
||||
|
||||
/// Writes the break of the paragraph that has ended.
|
||||
fn flush(&mut self) {
|
||||
let Some((para, mark)) = self.pending.take() else { return };
|
||||
if self.paras.len() >= MAX_PARAGRAPHS {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
self.paras.push(para);
|
||||
// A cell that starts with a break is still separated from the cell before it.
|
||||
if std::mem::take(&mut self.cell_tab) {
|
||||
self.push('\t', mark);
|
||||
}
|
||||
self.push('\n', mark);
|
||||
}
|
||||
|
||||
/// Adds `c` to the text in formatting `chars`.
|
||||
fn push(&mut self, c: char, chars: Chars) {
|
||||
self.len += c.len_utf8();
|
||||
if self.len > text::MAX_TEXT {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
let runs = self.spans.len();
|
||||
match self.spans.last_mut() {
|
||||
Some((s, f)) if *f == chars => s.push(c),
|
||||
_ if runs >= MAX_RUNS => self.full = true,
|
||||
_ => self.spans.push((c.to_string(), chars)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The character attributes of formatting `c`.
|
||||
fn style(&self, c: &Chars) -> CharStyle {
|
||||
let mut s = CharStyle::default();
|
||||
if let Some(name) = c.font.and_then(|i| self.fonts.get(i)) {
|
||||
s.font_family.clone_from(name);
|
||||
}
|
||||
s.font_style = match (c.bold, c.italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
}
|
||||
.into();
|
||||
if let Some(v) = c.half_points {
|
||||
s.size = (v / 2.0).clamp(0.1, 1296.0);
|
||||
}
|
||||
s.baseline_shift = (c.shift / 2.0).clamp(-1296.0, 1296.0);
|
||||
if let Some([r, g, b]) = c.color {
|
||||
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(r, g, b));
|
||||
}
|
||||
s.underline = c.underline;
|
||||
s.strikethrough = c.strike || c.dstrike;
|
||||
s.all_caps = c.caps;
|
||||
// Document Setup's proportions replace these when the type is placed.
|
||||
s.small_caps = c.small_caps.then_some(70.0);
|
||||
s.position = match c.script {
|
||||
Script::Normal => CharPosition::Normal,
|
||||
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
|
||||
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
|
||||
fn finish(mut self, cmd: &str) -> Result<Story> {
|
||||
if self.full {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {} MB of text)",
|
||||
text::MAX_TEXT >> 20
|
||||
),
|
||||
));
|
||||
}
|
||||
let last = self.pending.take().map_or(self.para, |(para, _)| para);
|
||||
self.paras.push(last);
|
||||
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
|
||||
while self.paras.len() > 1
|
||||
&& let Some(at) = all.rfind('\n')
|
||||
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
|
||||
{
|
||||
all.truncate(at);
|
||||
self.paras.pop();
|
||||
}
|
||||
let mut keep = all.len();
|
||||
let mut runs: Vec<TextRun> = vec![];
|
||||
for (s, chars) in &self.spans {
|
||||
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
|
||||
keep -= piece.len();
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = self.style(chars);
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.style == style => r.text.push_str(piece),
|
||||
_ => runs.push(TextRun::new(piece, style)),
|
||||
}
|
||||
}
|
||||
let points = |v: f64| (v / 20.0).clamp(-1296.0, 1296.0);
|
||||
let paras = self
|
||||
.paras
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let rtl = p.rtl == Some(true);
|
||||
let (left, right) = if rtl { (p.end, p.start) } else { (p.start, p.end) };
|
||||
ParaStyle {
|
||||
justify: match p.align {
|
||||
Align::Left => Justify::Left,
|
||||
Align::Center => Justify::Center,
|
||||
Align::Right => Justify::Right,
|
||||
Align::Both if rtl => Justify::JustifyRight,
|
||||
Align::Both => Justify::JustifyLeft,
|
||||
Align::Distribute => Justify::JustifyAll,
|
||||
Align::Start => Justify::Auto,
|
||||
Align::End if rtl => Justify::Left,
|
||||
Align::End => Justify::Right,
|
||||
},
|
||||
left_indent: points(left),
|
||||
right_indent: points(right),
|
||||
first_line_indent: points(p.first),
|
||||
space_before: points(p.before),
|
||||
space_after: points(p.after),
|
||||
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
|
||||
..ParaStyle::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Story { runs, paras })
|
||||
}
|
||||
}
|
||||
|
||||
/// Font name `name` trimmed, and cut at the last character that ends within [`MAX_NAME`] bytes.
|
||||
fn font_name(name: &str) -> String {
|
||||
let name = name.trim();
|
||||
let end = (0..=MAX_NAME.min(name.len())).rev().find(|&i| name.is_char_boundary(i)).unwrap_or_default();
|
||||
name.get(..end).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// The paragraph properties of a `w:pPr` element.
|
||||
fn para_props(ppr: Node) -> ParaProps {
|
||||
let mut p = ParaProps::default();
|
||||
for n in ppr.children() {
|
||||
match local(n) {
|
||||
Some("jc") => {
|
||||
p.align = match attr(n, "val") {
|
||||
Some("left") => Some(Align::Left),
|
||||
Some("center") => Some(Align::Center),
|
||||
Some("right") => Some(Align::Right),
|
||||
Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both),
|
||||
Some("distribute" | "thaiDistribute") => Some(Align::Distribute),
|
||||
Some("start") => Some(Align::Start),
|
||||
Some("end") => Some(Align::End),
|
||||
_ => p.align,
|
||||
}
|
||||
}
|
||||
Some("ind") => {
|
||||
// `w:left` and `w:right` (Transitional) are the start and end indents too.
|
||||
p.start = twips(n, "start").or_else(|| twips(n, "left")).or(p.start);
|
||||
p.end = twips(n, "end").or_else(|| twips(n, "right")).or(p.end);
|
||||
// A hanging indent wins over a first-line indent.
|
||||
p.first = twips(n, "hanging").map(|v| -v).or_else(|| twips(n, "firstLine")).or(p.first);
|
||||
}
|
||||
Some("spacing") => {
|
||||
p.before = twips(n, "before").or(p.before);
|
||||
p.after = twips(n, "after").or(p.after);
|
||||
}
|
||||
Some("bidi") => p.bidi = Some(on(n)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// `n`'s children named `name`, and those inside content controls and custom XML among them.
|
||||
fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> {
|
||||
let mut out = vec![];
|
||||
if depth > MAX_DEPTH {
|
||||
return out;
|
||||
}
|
||||
for c in n.children() {
|
||||
match local(c) {
|
||||
Some(l) if l == name => out.push(c),
|
||||
Some("sdt") => {
|
||||
if let Some(content) = child(c, "sdtContent") {
|
||||
out.extend(items(content, name, depth + 1));
|
||||
}
|
||||
}
|
||||
Some("customXml") => out.extend(items(c, name, depth + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn measures_read_numbers_and_units() {
|
||||
assert_eq!(measure("240", 20.0), Some(240.0));
|
||||
assert_eq!(measure("12pt", 20.0), Some(240.0));
|
||||
assert_eq!(measure("1in", 2.0), Some(144.0));
|
||||
assert_eq!(measure("2.54cm", 1.0).map(|v| (v * 1e9).round() / 1e9), Some(72.0));
|
||||
assert_eq!(measure("1e999", 20.0), None);
|
||||
assert_eq!(measure("wide", 20.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_counts_elements_outside_markup_that_is_not_an_element() {
|
||||
assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2);
|
||||
assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count");
|
||||
assert_eq!(nesting("</a></b><a>"), 1);
|
||||
assert_eq!(nesting("<a x=\"unterminated"), 1);
|
||||
assert_eq!(nesting(""), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_resolve_against_their_folder() {
|
||||
assert_eq!(resolve("", "word/document.xml"), "word/document.xml");
|
||||
assert_eq!(resolve("word/", "styles.xml"), "word/styles.xml");
|
||||
assert_eq!(resolve("word/", "../customXml/item1.xml"), "customXml/item1.xml");
|
||||
assert_eq!(resolve("word/", "/word/theme/./theme1.xml"), "word/theme/theme1.xml");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@
|
||||
//! (`crop: "bounding"`).
|
||||
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
||||
//! [`text`]).
|
||||
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
|
||||
//! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its
|
||||
//! styles and direct formatting give ([`docx`]).
|
||||
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
|
||||
//! one locked object showing the artboard `page`, linked to the file and read again when it
|
||||
//! changes ([`document`]); without Link, an editable copy of its art.
|
||||
@@ -27,6 +30,8 @@
|
||||
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod document;
|
||||
mod docx;
|
||||
mod rtf;
|
||||
mod text;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -96,8 +101,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// A file's art at its natural size, before it joins a document.
|
||||
enum Art {
|
||||
Image(RasterImage),
|
||||
/// A text file's text, placed as area type filling the natural box.
|
||||
Text(String),
|
||||
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
|
||||
/// natural box.
|
||||
Text(text::Story),
|
||||
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
|
||||
/// bounds (not the artboard) are shown.
|
||||
Document(document::Source, LinkInfo, bool),
|
||||
@@ -142,7 +148,24 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
let art = Art::Text(text::Story::plain(text));
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art, natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
// An RTF file is recognized by its content, whatever its name says (a .txt file is plain text).
|
||||
if rtf::is_rtf(&src.bytes) {
|
||||
let story = rtf::import(&src.bytes, cmd)?;
|
||||
if story.plain_text().trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &rtf::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
// A Word document is recognized by its name or by its package's `word/document.xml`.
|
||||
if docx::is_docx(src.name, &src.bytes) {
|
||||
let story = docx::import(&src.bytes, cmd)?;
|
||||
if story.plain_text().trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &docx::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
let format = fileio::detect(src.name, &src.bytes)
|
||||
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
@@ -298,7 +321,7 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural, &d.setup)))),
|
||||
Art::Document(s, link, bounding) => document::node(d, &s, link, bounding),
|
||||
Art::Vector { src, mut nodes, clip } => {
|
||||
adopt::adopt(d, &src, &mut nodes);
|
||||
@@ -437,9 +460,9 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
|
||||
/// The artboard under `at`, else the first.
|
||||
/// The artboard under `at` (its edges included, [`Document::artboard_holding`]), else the first.
|
||||
fn board_at(d: &Document, at: Option<Point>) -> Option<Rect> {
|
||||
at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map(|a| a.rect)
|
||||
at.and_then(|c| d.artboard_holding(c, false)).and_then(|i| d.artboards.get(i)).or(d.artboards.first()).map(|a| a.rect)
|
||||
}
|
||||
|
||||
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
//! Placing an RTF file: its text set as area type with the character and paragraph formatting
|
||||
//! VectorCraft has, read as the Rich Text Format (RTF) Specification 1.9.1 describes the format
|
||||
//! (<https://www.microsoft.com/en-us/download/details.aspx?id=10725>).
|
||||
//!
|
||||
//! - Text: `\par`, `\line` and `\sect` start a new paragraph, and so does `\row` (the end of a
|
||||
//! table row, whose cells, ended by `\cell` or `\nestcell`, are separated by tabs). `\tab` is a
|
||||
//! tab, `\uN` a Unicode character (the `\ucN` fallback characters after it are skipped), and
|
||||
//! `\'hh` a byte in Windows-1252, or in Mac Roman after `\mac` or `\ansicpg10000`. Of a `\upr`
|
||||
//! group's ANSI and Unicode (`\*\ud`) groups, the Unicode one is read. The control
|
||||
//! symbols `\~`, `\-` and `\_` and the control words for quotes, dashes, bullets and special
|
||||
//! spaces are their characters.
|
||||
//! - Character formatting, reset by `\plain`: the font table's font (`\f`, else `\deff`), the size
|
||||
//! (`\fs`), bold, italic, underline (every underline style), strikethrough, all caps, small caps,
|
||||
//! superscript and subscript, baseline shift (`\up`, `\dn`) and the color table's color (`\cf`).
|
||||
//! - Paragraph formatting, reset by `\pard`: alignment (`\ql`, `\qc`, `\qr`, `\qj`, `\qd`), left,
|
||||
//! right and first-line indents, space before and after, and direction (`\rtlpar`, `\ltrpar`).
|
||||
//!
|
||||
//! Other control words are skipped, and so are hidden text (`\v`) and the groups of destinations
|
||||
//! that are not the document's text: every `\*` destination, the style sheet, document
|
||||
//! information, pictures, objects, shapes, headers, footers, notes, note separators, the text for
|
||||
//! readers without nested tables (`\nonesttables`) and field instructions (a field's result is
|
||||
//! read).
|
||||
//! Groups nested deeper than [`MAX_GROUPS`] share the formatting of the deepest one read, and a
|
||||
//! file with more than [`MAX_PARAGRAPHS`] paragraphs or [`MAX_RUNS`] runs of one formatting is
|
||||
//! refused.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
|
||||
|
||||
use super::text::{self, Story};
|
||||
use super::*;
|
||||
|
||||
/// RTF files as `file.place` reports them (not a format `document.open` reads).
|
||||
pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
id: "rtf",
|
||||
label: "RTF",
|
||||
extensions: fileio::RTF_EXTS,
|
||||
mime: "application/rtf",
|
||||
read: true,
|
||||
write: false,
|
||||
raster: false,
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The deepest group nesting read; deeper groups share the formatting of the deepest one read.
|
||||
const MAX_GROUPS: usize = 512;
|
||||
/// The most fonts and colors the font and color tables hold.
|
||||
const MAX_TABLE: usize = 4096;
|
||||
/// The reader adds no more characters to a font name once it holds this many bytes.
|
||||
const MAX_NAME: usize = 256;
|
||||
/// The most paragraphs placed type takes from one RTF file.
|
||||
const MAX_PARAGRAPHS: usize = 100_000;
|
||||
/// The most runs of one character formatting placed type takes from one RTF file.
|
||||
const MAX_RUNS: usize = 100_000;
|
||||
|
||||
/// Does `bytes` hold an RTF file (`{\rtf` after any white space)?
|
||||
pub(super) fn is_rtf(bytes: &[u8]) -> bool {
|
||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(bytes.len());
|
||||
bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf"))
|
||||
}
|
||||
|
||||
/// The text of an RTF file's `bytes` with its character and paragraph formatting.
|
||||
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
|
||||
if bytes.len() > text::MAX_TEXT {
|
||||
return Err(bad(cmd, format!("the RTF file is too large to place as type (at most {} MB)", text::MAX_TEXT >> 20)));
|
||||
}
|
||||
if !is_rtf(bytes) {
|
||||
return Err(bad(cmd, "not an RTF file: it doesn't start with {\\rtf"));
|
||||
}
|
||||
let mut r = Reader::default();
|
||||
let mut i = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||
let mut starred = false;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if r.full {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
match b {
|
||||
b'{' => {
|
||||
starred = false;
|
||||
r.open();
|
||||
}
|
||||
b'}' => {
|
||||
starred = false;
|
||||
// The end of the outermost group is the end of the document.
|
||||
if !r.close() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
b'\\' => {
|
||||
let Some(&next) = bytes.get(i) else { break };
|
||||
if next.is_ascii_alphabetic() {
|
||||
let (word, n, end) = control_word(bytes, i);
|
||||
i = end;
|
||||
if word == "bin" {
|
||||
// Binary data: never text, and one character of a `\uN` fallback.
|
||||
i = i.saturating_add(usize::try_from(n.unwrap_or(0)).unwrap_or(0)).min(bytes.len());
|
||||
r.skip = r.skip.saturating_sub(1);
|
||||
continue;
|
||||
}
|
||||
r.word(word, n, std::mem::take(&mut starred));
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
match next {
|
||||
b'*' => starred = true,
|
||||
b'\'' => {
|
||||
if let Some(v) = bytes.get(i..i + 2).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
i += 2;
|
||||
r.byte(v);
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => r.word("par", None, false),
|
||||
b'\t' => r.put('\t'),
|
||||
b'~' => r.put('\u{A0}'),
|
||||
b'-' => r.put('\u{AD}'),
|
||||
b'_' => r.put('\u{2011}'),
|
||||
b'\\' | b'{' | b'}' => r.put(char::from(next)),
|
||||
// Other control symbols (index entries, formula characters) count as one
|
||||
// character of a `\uN` fallback.
|
||||
_ => r.skip = r.skip.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
// Line breaks in the file are not text.
|
||||
b'\r' | b'\n' => {}
|
||||
b'\t' => r.put('\t'),
|
||||
0..=0x1F => {}
|
||||
_ => r.byte(b),
|
||||
}
|
||||
}
|
||||
r.finish(cmd)
|
||||
}
|
||||
|
||||
/// The control word whose letters start at `bytes[at]`: its name (at most 32 letters), its numeric
|
||||
/// parameter, and where the text after it starts (a space after it belongs to the word).
|
||||
fn control_word(bytes: &[u8], at: usize) -> (&str, Option<i32>, usize) {
|
||||
let rest = bytes.get(at..).unwrap_or_default();
|
||||
let letters = rest.iter().take(32).take_while(|b| b.is_ascii_alphabetic()).count();
|
||||
let word = rest.get(..letters).and_then(|w| std::str::from_utf8(w).ok()).unwrap_or_default();
|
||||
let mut end = at + letters;
|
||||
let negative = bytes.get(end) == Some(&b'-') && bytes.get(end + 1).is_some_and(u8::is_ascii_digit);
|
||||
if negative {
|
||||
end += 1;
|
||||
}
|
||||
let digits = bytes.get(end..).unwrap_or_default().iter().take(10).take_while(|b| b.is_ascii_digit()).count();
|
||||
let n = bytes
|
||||
.get(end..end + digits)
|
||||
.filter(|d| !d.is_empty())
|
||||
.and_then(|d| std::str::from_utf8(d).ok())
|
||||
.and_then(|d| d.parse::<i64>().ok())
|
||||
.map(|v| (if negative { -v } else { v }).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32);
|
||||
end += digits;
|
||||
if bytes.get(end) == Some(&b' ') {
|
||||
end += 1;
|
||||
}
|
||||
(word, n, end)
|
||||
}
|
||||
|
||||
/// What the characters of the current group are part of.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Dest {
|
||||
/// The document's text.
|
||||
Body,
|
||||
/// The font table: font names.
|
||||
Fonts,
|
||||
/// The color table: `\red`, `\green` and `\blue` entries ended by `;`.
|
||||
Colors,
|
||||
/// Anything else: skipped.
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Script {
|
||||
#[default]
|
||||
Normal,
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Character formatting (what `\plain` resets).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Chars {
|
||||
/// The font table's font number; None: the default font (`\deff`).
|
||||
font: Option<i32>,
|
||||
/// Size in half points.
|
||||
half_points: i32,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strike: bool,
|
||||
caps: bool,
|
||||
small_caps: bool,
|
||||
script: Script,
|
||||
/// Baseline shift in half points (up positive).
|
||||
shift: i32,
|
||||
/// The color table's color number; a number without an entry, or whose entry has no values, gives the default color.
|
||||
color: i32,
|
||||
/// Hidden text (`\v`): not placed.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for Chars {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font: None,
|
||||
half_points: 24,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strike: false,
|
||||
caps: false,
|
||||
small_caps: false,
|
||||
script: Script::Normal,
|
||||
shift: 0,
|
||||
color: 0,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph formatting (what `\pard` resets); lengths in twips.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Para {
|
||||
justify: Justify,
|
||||
left: i32,
|
||||
right: i32,
|
||||
first: i32,
|
||||
before: i32,
|
||||
after: i32,
|
||||
rtl: Option<bool>,
|
||||
}
|
||||
|
||||
/// The state a group keeps: it is restored when the group ends.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Group {
|
||||
dest: Dest,
|
||||
chars: Chars,
|
||||
para: Para,
|
||||
/// How many fallback characters follow each `\uN`.
|
||||
uc: usize,
|
||||
/// A `\upr` group: of its two groups, the ANSI one is skipped and the `\*\ud` one is read.
|
||||
upr: bool,
|
||||
}
|
||||
|
||||
impl Default for Group {
|
||||
fn default() -> Self {
|
||||
Self { dest: Dest::Body, chars: Chars::default(), para: Para::default(), uc: 1, upr: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reader {
|
||||
group: Group,
|
||||
stack: Vec<Group>,
|
||||
/// Groups opened past [`MAX_GROUPS`] and not yet closed.
|
||||
overflow: usize,
|
||||
/// `\'hh` bytes are Mac Roman (else Windows-1252).
|
||||
mac: bool,
|
||||
default_font: i32,
|
||||
fonts: HashMap<i32, String>,
|
||||
/// The font the font table is naming.
|
||||
font: i32,
|
||||
font_name: String,
|
||||
colors: Vec<Option<[u8; 3]>>,
|
||||
rgb: [Option<u8>; 3],
|
||||
/// Fallback characters of a `\uN` still to skip.
|
||||
skip: usize,
|
||||
/// The high surrogate of a character written as two `\uN`.
|
||||
high: Option<u16>,
|
||||
/// A cell just ended: a tab goes before the next character of its row.
|
||||
cell_tab: bool,
|
||||
/// The text, in runs of one character formatting; paragraphs end with `\n`.
|
||||
spans: Vec<(String, Chars)>,
|
||||
/// The formatting of each paragraph that has ended.
|
||||
paras: Vec<Para>,
|
||||
/// The formatting of the paragraph being read, as at its last character or paragraph
|
||||
/// formatting word.
|
||||
last: Para,
|
||||
/// Past [`MAX_PARAGRAPHS`] or [`MAX_RUNS`].
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn open(&mut self) {
|
||||
self.skip = 0;
|
||||
if self.stack.len() >= MAX_GROUPS {
|
||||
self.overflow += 1;
|
||||
} else {
|
||||
self.stack.push(self.group);
|
||||
if std::mem::take(&mut self.group.upr) {
|
||||
self.group.dest = Dest::Skip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a group; false when it was the outermost one.
|
||||
fn close(&mut self) -> bool {
|
||||
self.skip = 0;
|
||||
if self.overflow > 0 {
|
||||
self.overflow -= 1;
|
||||
return true;
|
||||
}
|
||||
if self.group.dest == Dest::Fonts {
|
||||
self.commit_font();
|
||||
}
|
||||
match self.stack.pop() {
|
||||
Some(g) => {
|
||||
self.group = g;
|
||||
!self.stack.is_empty()
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name);
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && self.fonts.len() < MAX_TABLE {
|
||||
self.fonts.insert(self.font, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_color(&mut self) {
|
||||
let rgb = std::mem::take(&mut self.rgb);
|
||||
if self.colors.len() < MAX_TABLE {
|
||||
self.colors.push(rgb.iter().any(Option::is_some).then(|| rgb.map(|v| v.unwrap_or(0))));
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte `b` of the file's character set.
|
||||
fn byte(&mut self, b: u8) {
|
||||
self.put(if self.mac { text::mac_roman(b) } else { text::windows_1252(b) });
|
||||
}
|
||||
|
||||
/// Character `c` read from the file: skipped when it is a `\uN` fallback character.
|
||||
fn put(&mut self, c: char) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return;
|
||||
}
|
||||
self.high = None;
|
||||
self.text(c);
|
||||
}
|
||||
|
||||
/// Character `c` in the current destination.
|
||||
fn text(&mut self, c: char) {
|
||||
match self.group.dest {
|
||||
Dest::Body if self.group.chars.hidden => {}
|
||||
Dest::Body => {
|
||||
if std::mem::take(&mut self.cell_tab) {
|
||||
self.push('\t');
|
||||
}
|
||||
self.push(c);
|
||||
}
|
||||
Dest::Fonts if c == ';' => self.commit_font(),
|
||||
Dest::Fonts if self.font_name.len() < MAX_NAME => self.font_name.push(c),
|
||||
Dest::Colors if c == ';' => self.commit_color(),
|
||||
Dest::Fonts | Dest::Colors | Dest::Skip => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `c` to the document's text in the current character formatting.
|
||||
fn push(&mut self, c: char) {
|
||||
let chars = self.group.chars;
|
||||
let runs = self.spans.len();
|
||||
match self.spans.last_mut() {
|
||||
Some((s, f)) if *f == chars => s.push(c),
|
||||
_ if runs >= MAX_RUNS => {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
_ => self.spans.push((c.to_string(), chars)),
|
||||
}
|
||||
self.last = self.group.para;
|
||||
}
|
||||
|
||||
/// Ends the paragraph being read (unless its mark is hidden text).
|
||||
fn end_paragraph(&mut self) {
|
||||
if self.group.dest != Dest::Body || self.group.chars.hidden {
|
||||
return;
|
||||
}
|
||||
self.cell_tab = false;
|
||||
if self.paras.len() >= MAX_PARAGRAPHS {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
self.paras.push(self.group.para);
|
||||
self.push('\n');
|
||||
}
|
||||
|
||||
/// Unicode character (or UTF-16 code unit) `n`, a negative `n` standing for `n + 65536`.
|
||||
fn unicode(&mut self, n: i32) {
|
||||
let unit = n.rem_euclid(0x10000) as u16;
|
||||
match (self.high.take(), unit) {
|
||||
(Some(high), 0xDC00..=0xDFFF) => {
|
||||
if let Some(Ok(c)) = char::decode_utf16([high, unit]).next() {
|
||||
self.text(c);
|
||||
}
|
||||
}
|
||||
(_, 0xD800..=0xDBFF) => self.high = Some(unit),
|
||||
(_, 0xDC00..=0xDFFF) => {}
|
||||
(_, unit) => {
|
||||
if let Some(c) = char::from_u32(u32::from(unit)) {
|
||||
self.text(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.skip = self.group.uc;
|
||||
}
|
||||
|
||||
/// Control word `w` with parameter `n`; `starred` when `\*` came before it.
|
||||
fn word(&mut self, w: &str, n: Option<i32>, starred: bool) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return;
|
||||
}
|
||||
if w != "u" {
|
||||
self.high = None;
|
||||
}
|
||||
// The Unicode group of a `\upr` group is read in place of its ANSI group.
|
||||
if w == "ud"
|
||||
&& self.overflow == 0
|
||||
&& let Some(parent) = self.stack.last().filter(|g| g.upr)
|
||||
{
|
||||
self.group.dest = parent.dest;
|
||||
return;
|
||||
}
|
||||
// An ignorable destination (`\*`): VectorCraft reads none of them.
|
||||
if starred {
|
||||
self.group.dest = Dest::Skip;
|
||||
return;
|
||||
}
|
||||
if self.group.dest == Dest::Skip {
|
||||
return;
|
||||
}
|
||||
// `\b`, `\b1`: on; `\b0`: off.
|
||||
let on = n != Some(0);
|
||||
let v = n.unwrap_or(0);
|
||||
let para = self.group.para;
|
||||
let c = &mut self.group.chars;
|
||||
let p = &mut self.group.para;
|
||||
match w {
|
||||
"fonttbl" => self.group.dest = Dest::Fonts,
|
||||
"colortbl" => self.group.dest = Dest::Colors,
|
||||
"upr" => self.group.upr = true,
|
||||
"stylesheet" | "info" | "pict" | "object" | "shp" | "nonshppict" | "header" | "headerl" | "headerr" | "headerf" | "footer"
|
||||
| "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "fldinst" | "pn" | "listtable" | "listoverridetable" | "revtbl"
|
||||
| "rsidtbl" | "filetbl" | "bkmkstart" | "bkmkend" | "xe" | "tc" | "txe" | "template" | "ftnsep" | "ftnsepc" | "ftncn" | "aftnsep"
|
||||
| "aftnsepc" | "aftncn" | "nonesttables" => self.group.dest = Dest::Skip,
|
||||
"ansi" | "pc" | "pca" => self.mac = false,
|
||||
"mac" => self.mac = true,
|
||||
"ansicpg" => self.mac = v == 10000,
|
||||
"deff" => self.default_font = v,
|
||||
"f" if self.group.dest == Dest::Fonts => {
|
||||
self.commit_font();
|
||||
self.font = v;
|
||||
}
|
||||
"f" => c.font = Some(v),
|
||||
"red" | "green" | "blue" if self.group.dest == Dest::Colors => {
|
||||
let i = match w {
|
||||
"red" => 0,
|
||||
"green" => 1,
|
||||
_ => 2,
|
||||
};
|
||||
if let Some(slot) = self.rgb.get_mut(i) {
|
||||
*slot = Some(v.clamp(0, 255) as u8);
|
||||
}
|
||||
}
|
||||
"uc" => self.group.uc = usize::try_from(v).unwrap_or(0),
|
||||
"u" => self.unicode(v),
|
||||
"par" | "line" | "sect" => self.end_paragraph(),
|
||||
"row" | "nestrow" => {
|
||||
self.cell_tab = false;
|
||||
self.end_paragraph();
|
||||
}
|
||||
"cell" | "nestcell" if self.group.dest == Dest::Body && !c.hidden => {
|
||||
if self.cell_tab {
|
||||
self.push('\t');
|
||||
}
|
||||
self.cell_tab = true;
|
||||
}
|
||||
"tab" => self.text('\t'),
|
||||
"emdash" => self.text('\u{2014}'),
|
||||
"endash" => self.text('\u{2013}'),
|
||||
"bullet" => self.text('\u{2022}'),
|
||||
"lquote" => self.text('\u{2018}'),
|
||||
"rquote" => self.text('\u{2019}'),
|
||||
"ldblquote" => self.text('\u{201C}'),
|
||||
"rdblquote" => self.text('\u{201D}'),
|
||||
"emspace" => self.text('\u{2003}'),
|
||||
"enspace" => self.text('\u{2002}'),
|
||||
"qmspace" => self.text('\u{2005}'),
|
||||
"zwj" => self.text('\u{200D}'),
|
||||
"zwnj" => self.text('\u{200C}'),
|
||||
"ltrmark" => self.text('\u{200E}'),
|
||||
"rtlmark" => self.text('\u{200F}'),
|
||||
"plain" => *c = Chars::default(),
|
||||
"fs" => c.half_points = n.unwrap_or(24),
|
||||
"b" => c.bold = on,
|
||||
"i" => c.italic = on,
|
||||
"ul" | "uld" | "uldash" | "uldashd" | "uldashdd" | "uldb" | "ulhwave" | "ulldash" | "ulth" | "ulthd" | "ulthdash" | "ulthdashd"
|
||||
| "ulthdashdd" | "ulthldash" | "ululdbwave" | "ulw" | "ulwave" => c.underline = on,
|
||||
"ulnone" => c.underline = false,
|
||||
"strike" | "striked" => c.strike = on,
|
||||
"caps" => c.caps = on,
|
||||
"scaps" => c.small_caps = on,
|
||||
"super" => c.script = Script::Super,
|
||||
"sub" => c.script = Script::Sub,
|
||||
"nosupersub" => c.script = Script::Normal,
|
||||
"up" => c.shift = n.unwrap_or(6),
|
||||
"dn" => c.shift = n.unwrap_or(6).saturating_neg(),
|
||||
"cf" => c.color = v,
|
||||
"v" => c.hidden = on,
|
||||
"pard" => *p = Para::default(),
|
||||
"ql" => p.justify = Justify::Left,
|
||||
"qc" => p.justify = Justify::Center,
|
||||
"qr" => p.justify = Justify::Right,
|
||||
"qj" => p.justify = Justify::JustifyLeft,
|
||||
"qd" => p.justify = Justify::JustifyAll,
|
||||
"li" => p.left = v,
|
||||
"ri" => p.right = v,
|
||||
"fi" => p.first = v,
|
||||
"sb" => p.before = v,
|
||||
"sa" => p.after = v,
|
||||
"rtlpar" => p.rtl = Some(true),
|
||||
"ltrpar" => p.rtl = Some(false),
|
||||
_ => {}
|
||||
}
|
||||
// Paragraph formatting after a paragraph's last character still applies to it.
|
||||
if self.group.para != para && self.group.dest == Dest::Body {
|
||||
self.last = self.group.para;
|
||||
}
|
||||
}
|
||||
|
||||
/// The character attributes of formatting `c`.
|
||||
fn style(&self, c: &Chars) -> CharStyle {
|
||||
let mut s = CharStyle::default();
|
||||
if let Some(name) = self.fonts.get(&c.font.unwrap_or(self.default_font)) {
|
||||
s.font_family.clone_from(name);
|
||||
}
|
||||
s.font_style = match (c.bold, c.italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
}
|
||||
.into();
|
||||
s.size = (f64::from(c.half_points) / 2.0).clamp(0.1, 1296.0);
|
||||
s.baseline_shift = (f64::from(c.shift) / 2.0).clamp(-1296.0, 1296.0);
|
||||
if let Some(Some([r, g, b])) = usize::try_from(c.color).ok().and_then(|i| self.colors.get(i)) {
|
||||
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(*r, *g, *b));
|
||||
}
|
||||
s.underline = c.underline;
|
||||
s.strikethrough = c.strike;
|
||||
s.all_caps = c.caps;
|
||||
// Document Setup's proportions replace these when the type is placed.
|
||||
s.small_caps = c.small_caps.then_some(70.0);
|
||||
s.position = match c.script {
|
||||
Script::Normal => CharPosition::Normal,
|
||||
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
|
||||
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
|
||||
fn finish(mut self, cmd: &str) -> Result<Story> {
|
||||
let too_many = || {
|
||||
bad(
|
||||
cmd,
|
||||
format!(
|
||||
"the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs)"
|
||||
),
|
||||
)
|
||||
};
|
||||
if self.full {
|
||||
return Err(too_many());
|
||||
}
|
||||
self.paras.push(self.last);
|
||||
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
|
||||
while self.paras.len() > 1
|
||||
&& let Some(at) = all.rfind('\n')
|
||||
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
|
||||
{
|
||||
all.truncate(at);
|
||||
self.paras.pop();
|
||||
}
|
||||
if self.paras.len() > MAX_PARAGRAPHS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let mut keep = all.len();
|
||||
let mut runs: Vec<TextRun> = vec![];
|
||||
for (s, chars) in &self.spans {
|
||||
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
|
||||
keep -= piece.len();
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = self.style(chars);
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.style == style => r.text.push_str(piece),
|
||||
_ => runs.push(TextRun::new(piece, style)),
|
||||
}
|
||||
}
|
||||
let twips = |v: i32| (f64::from(v) / 20.0).clamp(-1296.0, 1296.0);
|
||||
let paras = self
|
||||
.paras
|
||||
.iter()
|
||||
.map(|p| ParaStyle {
|
||||
justify: p.justify,
|
||||
left_indent: twips(p.left),
|
||||
right_indent: twips(p.right),
|
||||
first_line_indent: twips(p.first),
|
||||
space_before: twips(p.before),
|
||||
space_after: twips(p.after),
|
||||
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
|
||||
..ParaStyle::default()
|
||||
})
|
||||
.collect();
|
||||
Ok(Story { runs, paras })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn read(rtf: &str) -> Story {
|
||||
import(rtf.as_bytes(), "file.place").unwrap()
|
||||
}
|
||||
|
||||
fn text_of(s: &Story) -> String {
|
||||
s.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_skip_their_fallback_characters() {
|
||||
// `\u` then 8364: the euro sign.
|
||||
let s = read(concat!(r"{\rtf1\ansi\uc1 caf\u233?\uc2 \u", "8364", r"\'80\'80 \uc1\u-10179?\u-8704? end\'93q\'94}"));
|
||||
assert_eq!(text_of(&s), "café€ 😀 end“q”");
|
||||
assert_eq!(text_of(&read(r"{\rtf1\uc1\u233\bin1 ?X}")), "éX", "binary data is one fallback character");
|
||||
let mac = read(r"{\rtf1\mac \'8e\'a5}");
|
||||
assert_eq!(text_of(&mac), "é•", "Mac Roman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upr_groups_read_their_unicode_text() {
|
||||
let s = read(concat!(r"{\rtf1 a{\upr{\f0 b?}{\*\ud{\f0 c\u", "945", r"?}}}d}"));
|
||||
assert_eq!(text_of(&s), "acαd");
|
||||
let fonts = read(r"{\rtf1{\fonttbl{\upr{\f0 Ansi Name;}{\*\ud{\f0 Unicode Name;}}}}x}");
|
||||
assert_eq!(fonts.runs.first().map(|r| r.style.font_family.as_str()), Some("Unicode Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destinations_that_are_not_text_are_skipped() {
|
||||
let s = read(concat!(
|
||||
r"{\rtf1{\fonttbl{\f0 Arial;}}{\*\generator Some Writer;}{\info{\title T}}{\stylesheet{\s1 Heading;}}",
|
||||
r"{\header H}A{\*\bkmkstart b}{\field{\*\fldinst HYPERLINK x}{\fldrslt link}}{\pict\pngblip 89504e47}\bin2 {}B}",
|
||||
"trailing"
|
||||
));
|
||||
assert_eq!(text_of(&s), "AlinkB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_and_the_text_for_older_readers_are_skipped() {
|
||||
let s = read(r"{\rtf1 One{\v hidden\par more\cell\par}\sect Two {\nonesttables flat copy}three}");
|
||||
assert_eq!(text_of(&s), "One\nTwo three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_are_tabs_and_rows_paragraphs() {
|
||||
let s = read(r"{\rtf1\trowd\cellx100\cellx200 a\cell b\cell\row\trowd c\cell\cell d\cell\row}");
|
||||
assert_eq!(text_of(&s), "a\tb\nc\t\td");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_formatting_after_the_last_character_applies() {
|
||||
let s = read(r"{\rtf1 One\par Two\qc\li400}");
|
||||
let justify: Vec<Justify> = s.paras.iter().map(|p| p.justify).collect();
|
||||
assert_eq!(justify, [Justify::Left, Justify::Center]);
|
||||
assert_eq!(s.paras.last().map(|p| p.left_indent), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_and_stray_braces_end_cleanly() {
|
||||
let deep = format!("{{\\rtf1 {}x{}}}", "{".repeat(5000), "}".repeat(5000));
|
||||
assert_eq!(text_of(&read(&deep)), "x");
|
||||
assert_eq!(text_of(&read(r"{\rtf1 unclosed {\b group")), "unclosed group");
|
||||
assert!(import(b"{\\rt", "file.place").is_err(), "not RTF");
|
||||
assert!(import(b"Plain words", "file.place").is_err(), "not RTF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_paragraphs_or_runs_are_refused() {
|
||||
let most = format!("{{\\rtf1 {}}}", "a\\par ".repeat(MAX_PARAGRAPHS));
|
||||
assert_eq!(read(&most).paras.len(), MAX_PARAGRAPHS);
|
||||
let paras = format!("{{\\rtf1 {}a}}", "a\\par ".repeat(MAX_PARAGRAPHS));
|
||||
assert!(import(paras.as_bytes(), "file.place").is_err());
|
||||
let marks = format!("{{\\rtf1 {}}}", "a\\par ".repeat(MAX_PARAGRAPHS + 1));
|
||||
assert!(import(marks.as_bytes(), "file.place").is_err());
|
||||
let runs = format!("{{\\rtf1 {}}}", "{\\b a}b".repeat(MAX_RUNS / 2 + 1));
|
||||
assert!(import(runs.as_bytes(), "file.place").is_err());
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,9 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The most bytes a text file placed as type may have.
|
||||
const MAX_TEXT: usize = 4 << 20;
|
||||
/// The most bytes a text or RTF file placed as type may have, and the most bytes of text a Word
|
||||
/// document may give.
|
||||
pub(super) const MAX_TEXT: usize = 4 << 20;
|
||||
|
||||
/// Text Import Options (`file.place`'s `text` param).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -95,7 +96,7 @@ fn decode(bytes: &[u8], o: TextOptions) -> String {
|
||||
}
|
||||
|
||||
/// Byte `b` of Windows-1252.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
pub(super) fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', //
|
||||
'\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
@@ -107,7 +108,7 @@ fn windows_1252(b: u8) -> char {
|
||||
}
|
||||
|
||||
/// Byte `b` of Mac Roman.
|
||||
fn mac_roman(b: u8) -> char {
|
||||
pub(super) fn mac_roman(b: u8) -> char {
|
||||
const HIGH: [char; 128] = [
|
||||
'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', //
|
||||
'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', //
|
||||
@@ -190,11 +191,41 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The area type of `text` filling `frame` (at the origin; placing moves it).
|
||||
pub(super) fn area_text(text: String, frame: Rect) -> vectorcraft_doc::TextObject {
|
||||
/// The text placed type gets from a text file, an RTF file or a Word document.
|
||||
pub(super) struct Story {
|
||||
/// The text in runs of one character formatting; paragraphs are separated by `\n`.
|
||||
pub(super) runs: Vec<vectorcraft_doc::TextRun>,
|
||||
/// Each paragraph's attributes; empty: the defaults.
|
||||
pub(super) paras: Vec<vectorcraft_doc::ParaStyle>,
|
||||
}
|
||||
|
||||
impl Story {
|
||||
/// `text` in the default character and paragraph attributes.
|
||||
pub(super) fn plain(text: String) -> Self {
|
||||
Self { runs: vec![vectorcraft_doc::TextRun::new(text, vectorcraft_doc::CharStyle::default())], paras: vec![] }
|
||||
}
|
||||
|
||||
/// The text without its formatting.
|
||||
pub(super) fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The area type of `story` filling `frame` (at the origin; placing moves it), its superscripts,
|
||||
/// subscripts and small caps in the proportions of `setup` (Document Setup).
|
||||
pub(super) fn area_text(story: Story, frame: Rect, setup: &vectorcraft_doc::DocSetup) -> vectorcraft_doc::TextObject {
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::ORIGIN, "", vectorcraft_doc::CharStyle::default());
|
||||
if let Some(run) = t.runs.first_mut() {
|
||||
run.text = text;
|
||||
if !story.runs.is_empty() {
|
||||
t.runs = story.runs;
|
||||
}
|
||||
for run in &mut t.runs {
|
||||
run.style.position = crate::cmd::docsetup::position_in(setup, run.style.position);
|
||||
if run.style.small_caps.is_some() {
|
||||
run.style.small_caps = Some(setup.small_caps_size);
|
||||
}
|
||||
}
|
||||
if !story.paras.is_empty() {
|
||||
t.set_paragraph_styles(story.paras);
|
||||
}
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`).
|
||||
//!
|
||||
//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional
|
||||
//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run
|
||||
//! Plug-ins are installed with `plugin.install` (kept across restarts in the apps' plug-in folder,
|
||||
//! [`Session::attach_plugin_store`]) or from every `*.wasm` in the Additional Plug-ins Folder
|
||||
//! preference, and listed with `plugin.list` / `plugin.info`. Object filters run
|
||||
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
|
||||
//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any
|
||||
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`.
|
||||
@@ -85,19 +86,20 @@ const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
|
||||
/// The `.wasm` extension plug-in files have (File › Open installs them).
|
||||
pub const EXTS: &[&str] = &["wasm"];
|
||||
|
||||
fn install(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
|
||||
let (plugin, bytes) = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
|
||||
(_, Some(data)) => {
|
||||
if data.len() > MAX_DATA_CHARS {
|
||||
return Err(bad(C, "`dataBase64` is larger than the module limit"));
|
||||
}
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?;
|
||||
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
|
||||
match str_param(p, "name") {
|
||||
let plugin = match str_param(p, "name") {
|
||||
Some(name) => plugin.with_source(name),
|
||||
None => plugin,
|
||||
}
|
||||
};
|
||||
(plugin, bytes)
|
||||
}
|
||||
(Some(path), None) => load_path(path)?,
|
||||
(None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")),
|
||||
@@ -105,17 +107,22 @@ fn install(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() {
|
||||
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541).
|
||||
s.store_plugin(plugin.id(), &bytes);
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
/// The plug-in in the `.wasm` file `path`, and its module.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_path(path: &str) -> Result<Plugin> {
|
||||
fn load_path(path: &str) -> Result<(Plugin, Vec<u8>)> {
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
|
||||
let limits = vectorcraft_plugins::Limits::default();
|
||||
let bytes = crate::store::read_capped(std::path::Path::new(path), limits.max_module_bytes as u64).map_err(EngineError::Other)?;
|
||||
Ok((Plugin::load(&bytes, limits).map_err(err)?.with_source(path), bytes))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_path(_: &str) -> Result<Plugin> {
|
||||
fn load_path(_: &str) -> Result<(Plugin, Vec<u8>)> {
|
||||
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
|
||||
}
|
||||
|
||||
@@ -186,8 +193,9 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn remove(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
|
||||
s.unstore_plugin(id);
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
|
||||
}
|
||||
|
||||
|
||||
@@ -89,8 +89,10 @@ pub const GPU_PREFERENCES: &[(&str, &str)] =
|
||||
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
|
||||
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
|
||||
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
|
||||
/// (winit 0.30 has no Wayland drag and drop, #1151).
|
||||
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
|
||||
/// (winit 0.30 has no Wayland drag and drop, #1151); `x11WithPen` does the same only when a pen is
|
||||
/// attached as the app starts: under XWayland a drawing tablet's pen draws as a mouse (#2028).
|
||||
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] =
|
||||
&[("automatic", "Automatic"), ("x11", "X11 (XWayland)"), ("x11WithPen", "X11 (XWayland) When a Pen Is Attached")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
@@ -138,6 +140,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
|
||||
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
|
||||
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
|
||||
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
|
||||
// Selection & Anchor Display
|
||||
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
|
||||
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_pdf::{TileOrigin, TilingPage};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Tiling",
|
||||
[],
|
||||
None,
|
||||
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
|
||||
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, its edges included, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
|
||||
has_doc,
|
||||
set_origin
|
||||
),
|
||||
@@ -77,6 +77,13 @@ fn point(p: &Value, key: &str) -> Option<Point> {
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
/// The first of the print `regions` holding `at`: one that holds it inside or on its left or top
|
||||
/// edge, else one that holds it on its right or bottom edge or within a millionth of a point
|
||||
/// outside an edge (the edges `Document::art_on_artboard` counts).
|
||||
fn region_holding(regions: &[(Option<usize>, Rect)], at: Point) -> Option<&(Option<usize>, Rect)> {
|
||||
regions.iter().find(|(_, r)| r.contains(at)).or_else(|| regions.iter().find(|(_, r)| vectorcraft_doc::artboard_holds(*r, at)))
|
||||
}
|
||||
|
||||
fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let current = settings(SET, doc, &json!({}))?;
|
||||
@@ -92,7 +99,7 @@ fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
|
||||
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
|
||||
}
|
||||
None => regions.iter().find(|(_, r)| r.contains(at)).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
None => region_holding(®ions, at).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
};
|
||||
let corner = picked.1.origin();
|
||||
TileOrigin { placed: true, x: at.x - corner.x, y: at.y - corner.y }
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
//! model, background, anti-aliasing, room around the art). Shadows and outer glows only add the
|
||||
//! effect: the image (with the object's own area knocked out) goes under the untouched vector
|
||||
//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it
|
||||
//! with the image. Expand Appearance makes its images with the same helpers.
|
||||
//! with the image. An object whose only raster effect is a Rasterize of its own is replaced with
|
||||
//! the image Object › Rasterize makes with that effect's options. Expand Appearance makes its
|
||||
//! images with the same helpers.
|
||||
//!
|
||||
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
|
||||
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
|
||||
//! into images the same way ([`flatten_pixel_effects`]).
|
||||
//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying
|
||||
//! one of those into images the same way ([`flatten_pixel_effects`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -159,6 +161,12 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64, anti_alias
|
||||
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The note for pixels drawn since [`vectorcraft_render::decode_failures`] stood at `failed`, when
|
||||
/// an image among them couldn't be decoded and so drew nothing; `None` otherwise.
|
||||
pub(crate) fn undecoded_warning(failed: u64) -> Option<&'static str> {
|
||||
(vectorcraft_render::decode_failures() > failed).then_some("an image couldn't be decoded and is missing from the picture")
|
||||
}
|
||||
|
||||
/// Pixels per point of raster effects rendered as images (the document's raster effects
|
||||
/// resolution).
|
||||
pub(crate) fn effects_scale(doc: &Document) -> f64 {
|
||||
@@ -283,11 +291,15 @@ fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
|
||||
}
|
||||
|
||||
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d`
|
||||
/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays
|
||||
/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds
|
||||
/// just them; with `members` too, a group's or layer's image leaves out its members' raster
|
||||
/// effects (they get images of their own).
|
||||
/// rendered at the document's raster effects resolution, or at a Rasterize effect's finer one (an
|
||||
/// object whose only raster effect is its own Rasterize gets [`rasterize_effect_image`]'s image),
|
||||
/// without `n`'s transparency (that stays on the object). With `below` (they all paint below it:
|
||||
/// shadows and outer glows) the image holds just them; with `members` too, a group's or layer's
|
||||
/// image leaves out its members' raster effects (they get images of their own).
|
||||
pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option<Node> {
|
||||
if !below && let Some(image) = rasterize_effect_image(d, n) {
|
||||
return Some(image);
|
||||
}
|
||||
let mut whole = n.clone();
|
||||
(whole.opacity, whole.blend) = (1.0, Default::default());
|
||||
whole.extras_mut().mask = None;
|
||||
@@ -301,10 +313,37 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo
|
||||
strip_raster(&mut b, false);
|
||||
b
|
||||
});
|
||||
let scale = effects_scale(d);
|
||||
// A Rasterize effect at a finer resolution than the document's is rendered at its own.
|
||||
let scale = raster_fx(n)
|
||||
.iter()
|
||||
.filter_map(|fx| if let RasterFx::Rasterize(r) = fx { r.ppi } else { None })
|
||||
.fold(effects_scale(d), |s, ppi| s.max(ppi / 72.0));
|
||||
effect_image(d, &whole, bare.as_ref(), scale)
|
||||
}
|
||||
|
||||
/// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when
|
||||
/// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n`
|
||||
/// without that effect and without its transparency, which stays on the object. `None` otherwise,
|
||||
/// or when the image can't be made.
|
||||
fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None };
|
||||
let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?;
|
||||
let p = json!({
|
||||
"ppi": r.ppi.unwrap_or(d.raster_effects_ppi),
|
||||
"colorModel": r.color_model.id(),
|
||||
"background": if r.white { "white" } else { "transparent" },
|
||||
"antiAlias": if !r.anti_alias { "none" } else if r.hinted { "type" } else { "art" },
|
||||
"clippingMask": r.clipping_mask,
|
||||
"addAround": r.add_around,
|
||||
});
|
||||
let mut whole = n.clone();
|
||||
whole.appearance.effects.remove(at);
|
||||
(whole.opacity, whole.blend) = (1.0, Default::default());
|
||||
whole.extras_mut().mask = None;
|
||||
let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?;
|
||||
Some(image.into_node(d))
|
||||
}
|
||||
|
||||
/// `v` (an object without its raster effects) replaced by `image` of the object with them: the
|
||||
/// image takes its place with its id, name and transparency (a layer keeps the image as its only
|
||||
/// member).
|
||||
@@ -403,7 +442,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
|
||||
/// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their
|
||||
/// raster effects).
|
||||
Pixel,
|
||||
}
|
||||
|
||||
@@ -412,7 +452,7 @@ fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,8 +553,8 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
}
|
||||
|
||||
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
|
||||
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
|
||||
/// other raster effects live), or `None` when there are none.
|
||||
/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its
|
||||
/// filters draw the other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
|
||||
}
|
||||
|
||||
@@ -697,7 +697,7 @@ pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> {
|
||||
if !st.is_dirty() {
|
||||
return Some("no unsaved changes");
|
||||
}
|
||||
if st.recovery.as_ref().is_some_and(|c| Arc::ptr_eq(&c.doc, &st.doc)) {
|
||||
if st.recovery.as_ref().is_some_and(|c| st.same_content(&c.doc)) {
|
||||
return Some("unchanged since its last recovery copy");
|
||||
}
|
||||
if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS {
|
||||
|
||||
@@ -259,9 +259,16 @@ fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
|
||||
// Symbol instances are measured by their art.
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation)
|
||||
.into_iter()
|
||||
.filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center())))
|
||||
.filter(|id| {
|
||||
st.doc
|
||||
.node(*id)
|
||||
.and_then(|n| n.geometric_bounds_with(&symbols))
|
||||
.is_some_and(|b| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center()))
|
||||
})
|
||||
.collect();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
|
||||
Loaded 100 of 398 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user