mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-17 20:56:55 -04:00
Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
299a4b36ad | ||
|
|
6a83f6fea6 | ||
|
|
e6aec0664c | ||
|
|
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 | ||
|
|
d44dc983e6 | ||
|
|
f7382b2844 | ||
|
|
039a467dcb | ||
|
|
1f304f6a23 | ||
|
|
45c2b0b363 | ||
|
|
85afe7caaf | ||
|
|
92e33b1579 | ||
|
|
fa9a52bd71 | ||
|
|
5f70f96a66 | ||
|
|
10ae4cc5a8 | ||
|
|
eeeb9645c1 | ||
|
|
e1a6e373d5 | ||
|
|
286a1fb8b3 | ||
|
|
949473f1e3 | ||
|
|
f999688b41 | ||
|
|
b5164825d6 | ||
|
|
56df1a1663 | ||
|
|
6a42b3bf10 | ||
|
|
5b85ca6358 | ||
|
|
e2b4cef111 | ||
|
|
f92c622e97 | ||
|
|
43356a1cfb | ||
|
|
b55d277b49 | ||
|
|
2a5dfa6a94 | ||
|
|
6190525482 | ||
|
|
39f9204d57 | ||
|
|
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 | ||
|
|
800db5b29d | ||
|
|
84ccee71ef | ||
|
|
82e363c73b | ||
|
|
b05043c759 | ||
|
|
ef5084f48b | ||
|
|
1ddfb56f66 | ||
|
|
b46ffd0059 | ||
|
|
fa46998006 |
No files matched your search
Generated
+10
@@ -806,6 +806,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -4617,6 +4622,7 @@ dependencies = [
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4650,12 +4656,14 @@ dependencies = [
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"roxmltree 0.21.1",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4859,6 +4867,7 @@ dependencies = [
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4883,6 +4892,7 @@ dependencies = [
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
|
||||
@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/vectorcraft"
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-3
@@ -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 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)
|
||||
> **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 33/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 |
|
||||
@@ -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 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). 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. |
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -389,6 +389,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
|
||||
|
||||
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
|
||||
fn reveal(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -559,8 +560,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
|
||||
/// no Windows file name has) is refused rather than split into other arguments.
|
||||
fn explorer_safe(path: &str) -> Result<(), String> {
|
||||
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
|
||||
fn open_file(path: &str) -> Result<(), String> {
|
||||
explorer_safe(path)?;
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
@@ -788,14 +796,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();
|
||||
@@ -875,6 +888,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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -126,3 +126,6 @@ sync_github_name = true
|
||||
|
||||
[people.dennismutuku2005]
|
||||
sync_github_name = true
|
||||
|
||||
[people.makesomethingshit]
|
||||
sync_github_name = true
|
||||
@@ -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 }
|
||||
|
||||
@@ -349,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::*;
|
||||
|
||||
+67
-16
@@ -311,8 +311,7 @@ fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom
|
||||
/// 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.
|
||||
@@ -321,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,
|
||||
@@ -493,7 +504,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
// `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().map(|c| (c, r)).collect(),
|
||||
(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
|
||||
@@ -529,13 +540,14 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}
|
||||
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, .. } => {
|
||||
marquee_in(n, r).is_some_and(|r| children.iter().any(|c| c.visible && touches(c, s, r)))
|
||||
}
|
||||
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| {
|
||||
@@ -544,18 +556,18 @@ 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, .. } => {
|
||||
let Some(r) = marquee_in(n, r) else { return };
|
||||
for c in children {
|
||||
if c.visible && !c.locked {
|
||||
collect_leaves(c, s, r, out);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -567,8 +579,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -771,6 +783,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1355,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)]
|
||||
|
||||
+20
-11
@@ -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",
|
||||
@@ -635,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
|
||||
}
|
||||
|
||||
@@ -784,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");
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
|
||||
/// 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) {
|
||||
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;
|
||||
@@ -212,7 +212,7 @@ pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
|
||||
/// 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) {
|
||||
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;
|
||||
@@ -248,7 +248,7 @@ pub(super) fn glow_spread(size: f64) -> f64 {
|
||||
/// 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) {
|
||||
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;
|
||||
|
||||
@@ -2133,6 +2133,49 @@ fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -15,6 +15,8 @@ default = ["bundled-fonts"]
|
||||
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
|
||||
|
||||
[dependencies]
|
||||
# Which paths stored in documents may be opened.
|
||||
craft-docpath = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
@@ -35,6 +37,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 +50,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 }
|
||||
@@ -62,7 +62,7 @@ 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
|
||||
),
|
||||
@@ -75,13 +75,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_delete
|
||||
),
|
||||
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
|
||||
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
|
||||
),
|
||||
@@ -393,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") {
|
||||
@@ -445,7 +454,8 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
|
||||
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);
|
||||
@@ -463,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")));
|
||||
@@ -669,7 +679,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = roots(s, p)?;
|
||||
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let name = 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();
|
||||
@@ -918,7 +928,8 @@ 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);
|
||||
|
||||
@@ -129,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)
|
||||
),
|
||||
@@ -859,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`.
|
||||
@@ -975,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;
|
||||
@@ -1044,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>> {
|
||||
@@ -1113,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);
|
||||
}
|
||||
@@ -130,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
|
||||
),
|
||||
@@ -139,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
|
||||
),
|
||||
@@ -218,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> {
|
||||
@@ -243,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.
|
||||
@@ -275,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,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()
|
||||
}
|
||||
|
||||
@@ -356,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> {
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +902,7 @@ 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);
|
||||
|
||||
@@ -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, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -270,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);
|
||||
@@ -293,8 +297,11 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
// Paths are checked as stored in a document inside `folder` (only the folder is used).
|
||||
let document = folder.map(|f| std::path::Path::new(f).join("-").to_string_lossy().into_owned());
|
||||
let read = |path: &str| {
|
||||
scope.clone().run(|| {
|
||||
let path = &super::document_path(path, document.as_deref()).ok()?;
|
||||
// Only files (not folders or devices) are read.
|
||||
file_stamp(path)?;
|
||||
let bytes = read_file(path).ok()?;
|
||||
@@ -470,6 +477,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
|
||||
@@ -504,7 +513,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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -710,7 +711,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
/// RTF files: File → Place sets them as area type with their formatting.
|
||||
pub const RTF_EXTS: &[&str] = &["rtf"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
|
||||
/// 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",
|
||||
@@ -741,6 +746,7 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"afpub",
|
||||
"txt",
|
||||
"rtf",
|
||||
"docx",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
@@ -764,13 +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
|
||||
/// and RTF.
|
||||
/// 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`).
|
||||
@@ -843,9 +850,36 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// `path` checked for reading against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_read(path: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
/// Paths read while [`touched`] runs, on any thread (the SVG importer reads on one of its own).
|
||||
#[cfg(test)]
|
||||
static TOUCHED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
#[cfg(test)]
|
||||
static RECORDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
fn touch(path: &str) {
|
||||
if RECORDING.load(std::sync::atomic::Ordering::SeqCst) > 0 {
|
||||
TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` and list the paths read through this module meanwhile (by any test running then).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let start = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len();
|
||||
RECORDING.fetch_add(1, SeqCst);
|
||||
let out = f();
|
||||
RECORDING.fetch_sub(1, SeqCst);
|
||||
let seen = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(start..).unwrap_or_default().to_vec();
|
||||
(out, seen)
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn may_write(path: &str) -> Result<()> {
|
||||
@@ -1003,8 +1037,7 @@ fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
|
||||
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
may_read(path).ok()?;
|
||||
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
|
||||
let m = file_metadata(path).filter(std::fs::Metadata::is_file)?;
|
||||
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Some((m.len(), modified))
|
||||
}
|
||||
@@ -1013,11 +1046,17 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
/// (and automation may read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_created(path: &str) -> Option<u64> {
|
||||
may_read(path).ok()?;
|
||||
let t = std::fs::metadata(path).ok()?.created().ok()?;
|
||||
let t = file_metadata(path)?.created().ok()?;
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
|
||||
}
|
||||
|
||||
/// The file system's metadata of `path`, following links (when automation may read it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_metadata(path: &str) -> Option<std::fs::Metadata> {
|
||||
may_read(path).ok()?;
|
||||
std::fs::metadata(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_created(_: &str) -> Option<u64> {
|
||||
None
|
||||
@@ -1033,6 +1072,21 @@ pub(crate) fn absolute_path(path: &str) -> String {
|
||||
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The file to open for `stored`, a path or `file:` URL stored in a document (a linked file, an
|
||||
/// SVG image's href), or why it is not followed. `document` is the path of the document it is
|
||||
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
|
||||
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
|
||||
/// path that is still relative (no document) is refused, as the working directory isn't the
|
||||
/// document's choice. Every stored path reaches the file system through this.
|
||||
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
|
||||
let document = document.map(absolute_path);
|
||||
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
|
||||
if !path.is_absolute() {
|
||||
return Err(craft_docpath::Refused::Invalid);
|
||||
}
|
||||
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option<u64>)> {
|
||||
None
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -408,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;
|
||||
}
|
||||
@@ -453,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() })),
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Check Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
|
||||
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Link Info",
|
||||
[],
|
||||
None,
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -245,21 +245,24 @@ fn found_at(path: String) -> Option<Found> {
|
||||
Some(Found { path, size, modified, bytes: None })
|
||||
}
|
||||
|
||||
/// Where the file of `link` is: at its path, else at its relative path or by name in the
|
||||
/// document's folder `dir`.
|
||||
fn locate(link: &LinkInfo, dir: Option<&Path>) -> Option<Found> {
|
||||
let mut paths = vec![link.path.clone()];
|
||||
if let Some(dir) = dir {
|
||||
let near = link.relative.iter().map(|r| dir.join(r)).chain([dir.join(link.name())]);
|
||||
paths.extend(near.map(|p| p.to_string_lossy().into_owned()));
|
||||
}
|
||||
paths.into_iter().find_map(found_at)
|
||||
/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at
|
||||
/// its relative path or by name in the document's folder. Only paths
|
||||
/// [`fileio::document_path`] follows are looked at.
|
||||
fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option<Found> {
|
||||
let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())];
|
||||
stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at)
|
||||
}
|
||||
|
||||
/// Where the file of group `g` is and whether it still has the content shown. Its bytes are read
|
||||
/// only when its size or modification time changed.
|
||||
fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, dir) else { return (Status::Missing, None) };
|
||||
/// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it
|
||||
/// names another computer, a device or a URL.
|
||||
fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option<craft_docpath::Refused> {
|
||||
fileio::document_path(&link.path, doc_path).err().filter(|r| *r != craft_docpath::Refused::Invalid)
|
||||
}
|
||||
|
||||
/// Where the file of group `g` (of the document saved at `doc_path`) is and whether it still has
|
||||
/// the content shown. Its bytes are read only when its size or modification time changed.
|
||||
fn probe(g: &Group, doc_path: Option<&str>) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) };
|
||||
let l = &g.link;
|
||||
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
|
||||
return (Status::Ok, Some(f));
|
||||
@@ -274,11 +277,6 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
|
||||
(if same { Status::Ok } else { Status::Modified }, Some(f))
|
||||
}
|
||||
|
||||
/// The folder of the document saved at `doc_path`.
|
||||
fn folder(doc_path: Option<&str>) -> Option<&Path> {
|
||||
doc_path.and_then(|p| Path::new(p).parent())
|
||||
}
|
||||
|
||||
/// Is image `key` of `d` showing its preview (its file not read)?
|
||||
fn previewing(d: &Document, key: &str) -> bool {
|
||||
d.images.get(key).is_none_or(ImageBlob::is_proxy)
|
||||
@@ -314,7 +312,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
.filter(|g| seen.insert(g.link.path.clone()))
|
||||
.map(|g| {
|
||||
let own = |b: &&ImageBlob| !b.is_proxy() && fileio::format_for_name(g.link.name()).is_some_and(|f| f.mime == b.mime);
|
||||
let found = locate(&g.link, folder(doc_path));
|
||||
let found = locate(&g.link, doc_path);
|
||||
let found_path = found.as_ref().map(|f| f.path.clone());
|
||||
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
|
||||
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
|
||||
@@ -328,7 +326,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec<NodeId>)> {
|
||||
groups(&st.doc, ids)
|
||||
.into_iter()
|
||||
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, folder(st.path.as_deref())).0 == Status::Missing))
|
||||
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing))
|
||||
.map(|g| (g.link, g.ids))
|
||||
.collect()
|
||||
}
|
||||
@@ -510,9 +508,23 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
|
||||
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
|
||||
}
|
||||
|
||||
/// Groups as `{name, path, ids}` rows (those with objects in the layers).
|
||||
fn rows<'a>(gs: impl Iterator<Item = &'a Group>) -> Vec<Value> {
|
||||
gs.filter(|g| !g.ids.is_empty()).map(|g| json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) })).collect()
|
||||
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
|
||||
/// the layers), with `refused`: why the path isn't followed, when it isn't.
|
||||
fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<Value> {
|
||||
gs.filter(|g| !g.ids.is_empty())
|
||||
.map(|g| {
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) });
|
||||
note_refused(&mut row, &g.link, doc_path);
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]).
|
||||
fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) {
|
||||
if let Some(r) = refused(link, doc_path) {
|
||||
row["refused"] = json!(r.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
|
||||
@@ -545,9 +557,9 @@ impl Resolved {
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
|
||||
let mut out = Resolved::default();
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g));
|
||||
let row = rows(std::iter::once(&g), doc_path);
|
||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||
match probe(&g, folder(doc_path)) {
|
||||
match probe(&g, doc_path) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update || unread => {
|
||||
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) {
|
||||
@@ -714,10 +726,11 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut missing, mut modified) = (0, 0);
|
||||
let mut links = vec![];
|
||||
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, folder(st.path.as_deref()));
|
||||
let (status, found) = probe(g, st.path.as_deref());
|
||||
missing += usize::from(status == Status::Missing);
|
||||
modified += usize::from(status == Status::Modified);
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
|
||||
note_refused(&mut row, &g.link, st.path.as_deref());
|
||||
if let Some(f) = found.filter(|f| f.path != g.link.path) {
|
||||
row["found"] = json!(f.path);
|
||||
}
|
||||
@@ -734,7 +747,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = ids_param(p, "ids");
|
||||
let (mut reads, mut missing) = (vec![], vec![]);
|
||||
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
|
||||
match probe(&g, folder(st.path.as_deref())) {
|
||||
match probe(&g, st.path.as_deref()) {
|
||||
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
|
||||
// Unchanged and showing the file: nothing to read unless asked for by id.
|
||||
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
|
||||
@@ -783,11 +796,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => {
|
||||
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
|
||||
match folder_param {
|
||||
Some(_) if selected.is_empty() => groups(&st.doc, None)
|
||||
.into_iter()
|
||||
.filter(|g| probe(g, folder(st.path.as_deref())).0 == Status::Missing)
|
||||
.flat_map(|g| g.ids)
|
||||
.collect(),
|
||||
Some(_) if selected.is_empty() => {
|
||||
groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect()
|
||||
}
|
||||
_ => selected,
|
||||
}
|
||||
}
|
||||
@@ -969,7 +980,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
|
||||
let mut out = BTreeMap::new();
|
||||
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, folder(st.path.as_deref()));
|
||||
let (status, found) = probe(g, st.path.as_deref());
|
||||
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
|
||||
let preview = stale(&st.doc, g);
|
||||
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
|
||||
@@ -1110,7 +1121,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
done.extend(g.ids);
|
||||
continue;
|
||||
}
|
||||
match probe(&g, folder(st.path.as_deref())) {
|
||||
match probe(&g, st.path.as_deref()) {
|
||||
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
|
||||
Ok(read) => {
|
||||
done.extend(&g.ids);
|
||||
@@ -1190,10 +1201,13 @@ fn placement_json(o: PlacementOptions) -> Value {
|
||||
|
||||
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
|
||||
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
|
||||
let stamp = file_stamp(&l.path);
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
|
||||
let doc_path = st.path.as_deref();
|
||||
let path = fileio::document_path(&l.path, doc_path).ok();
|
||||
let stamp = path.as_deref().and_then(file_stamp);
|
||||
let folder_len = l.path.len().saturating_sub(l.name().len());
|
||||
out["status"] = json!(status.as_str());
|
||||
note_refused(out, l, doc_path);
|
||||
out["path"] = json!(l.path);
|
||||
out["fileName"] = json!(l.name());
|
||||
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
|
||||
@@ -1201,7 +1215,7 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value)
|
||||
("page", l.page.map(u64::from)),
|
||||
("fileSize", stamp.map(|s| s.0).or(l.size)),
|
||||
("modified", stamp.and_then(|s| s.1).or(l.modified)),
|
||||
("created", file_created(&l.path)),
|
||||
("created", path.as_deref().and_then(file_created)),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
out[k] = json!(v);
|
||||
|
||||
@@ -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,6 +96,15 @@ 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",
|
||||
@@ -653,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)?;
|
||||
|
||||
@@ -446,21 +446,51 @@ fn render_png(
|
||||
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) => {
|
||||
@@ -470,50 +500,65 @@ 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, warning) = 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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
//! - 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.
|
||||
@@ -28,6 +30,7 @@
|
||||
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod document;
|
||||
mod docx;
|
||||
mod rtf;
|
||||
mod text;
|
||||
|
||||
@@ -98,7 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// A file's art at its natural size, before it joins a document.
|
||||
enum Art {
|
||||
Image(RasterImage),
|
||||
/// A text or RTF file's text, placed as area type filling the natural box.
|
||||
/// 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.
|
||||
@@ -155,6 +159,14 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
}
|
||||
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)")))?;
|
||||
let page = match p.get("page") {
|
||||
|
||||
@@ -17,7 +17,8 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The most bytes a text or RTF file placed as type may have.
|
||||
/// 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).
|
||||
@@ -190,7 +191,7 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The text placed type gets from a text or RTF file.
|
||||
/// 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>,
|
||||
|
||||
@@ -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")];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -289,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;
|
||||
@@ -307,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).
|
||||
@@ -409,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,
|
||||
}
|
||||
|
||||
@@ -418,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(_))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,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))
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", 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)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", 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: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -255,6 +255,7 @@ pub(crate) struct CharChange {
|
||||
char_align: Option<vectorcraft_doc::CharAlign>,
|
||||
proportional_metrics: Option<bool>,
|
||||
digits: Option<vectorcraft_doc::Digits>,
|
||||
font_axes: Option<AxisChange>,
|
||||
}
|
||||
|
||||
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
|
||||
@@ -270,6 +271,60 @@ pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_do
|
||||
}))
|
||||
}
|
||||
|
||||
/// Variable Font settings to change: by axis tag, a value in the axis's units, or `None` to go
|
||||
/// back to the style's own setting of that axis.
|
||||
pub(crate) type AxisChange = std::collections::BTreeMap<String, Option<f64>>;
|
||||
|
||||
/// `fontAxes: {"wght": 650, "wdth": null, …}` → the Variable Font settings to change. A tag is four
|
||||
/// printable ASCII characters (`wght`); a value is a number, or null.
|
||||
pub(crate) fn font_axes_param(p: &Value, cmd: &str) -> Result<Option<AxisChange>> {
|
||||
let Some(v) = p.get("fontAxes").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let obj = v.as_object().ok_or_else(|| bad(cmd, "`fontAxes` must be an object of axis tags and values, such as {\"wght\": 650}"))?;
|
||||
let mut out = AxisChange::new();
|
||||
for (tag, value) in obj {
|
||||
if tag.len() != 4 || !tag.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
|
||||
return Err(bad(cmd, format!("`{tag}` is not an axis tag: a tag is four characters, such as \"wght\"")));
|
||||
}
|
||||
let value = match value {
|
||||
Value::Null => None,
|
||||
v => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("the value of axis `{tag}` must be a number or null")))?),
|
||||
};
|
||||
out.insert(tag.clone(), value);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Change the Variable Font settings of `st` by `change`. With its font available, a value is
|
||||
/// clamped to its axis's range, a tag its font has no axis for is left out, and a value equal to
|
||||
/// the style's own setting is removed; with the font missing, the values are kept as given.
|
||||
pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let face = match db.resolve(&st.font_family, &st.font_style) {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
|
||||
Some(db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).unwrap_or(f))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let axes = face.as_ref().map(|f| f.axes()).unwrap_or_default();
|
||||
for (tag, value) in change {
|
||||
let Some(v) = value else {
|
||||
st.font_axes.remove(tag);
|
||||
continue;
|
||||
};
|
||||
let Some(face) = &face else {
|
||||
st.font_axes.insert(tag.clone(), *v);
|
||||
continue;
|
||||
};
|
||||
let Some(axis) = axes.iter().find(|a| a.tag.as_slice() == tag.as_bytes()) else { continue };
|
||||
let v = v.clamp(f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
|
||||
if face.axis_value(axis.tag).is_some_and(|own| f64::from(own) == v) {
|
||||
st.font_axes.remove(tag);
|
||||
} else {
|
||||
st.font_axes.insert(tag.clone(), v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
|
||||
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
@@ -358,6 +413,7 @@ impl CharChange {
|
||||
char_align: char_align_param(p, cmd)?,
|
||||
proportional_metrics: flag("proportionalMetrics"),
|
||||
digits: digits_param(p, cmd)?,
|
||||
font_axes: font_axes_param(p, cmd)?,
|
||||
};
|
||||
if c.size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(cmd, "size must be positive"));
|
||||
@@ -388,6 +444,7 @@ impl CharChange {
|
||||
&& self.char_align.is_none()
|
||||
&& self.proportional_metrics.is_none()
|
||||
&& self.digits.is_none()
|
||||
&& self.font_axes.is_none()
|
||||
}
|
||||
|
||||
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
|
||||
@@ -416,9 +473,13 @@ impl CharChange {
|
||||
if let Some(v) = &self.style {
|
||||
st.font_style = v.clone();
|
||||
}
|
||||
// Another font: none of the old one's versions.
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
if self.font.is_some() || self.style.is_some() {
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(v) = &self.font_axes {
|
||||
change_font_axes(st, v);
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
st.size = v;
|
||||
@@ -484,7 +545,8 @@ impl CharChange {
|
||||
}
|
||||
|
||||
/// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new
|
||||
/// font has no glyph for, but their font `before` the change had, keep that font.
|
||||
/// font has no glyph for, but their font `before` the change had, keep that font: its family,
|
||||
/// style, version and Variable Font settings.
|
||||
pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>) {
|
||||
fn chars(runs: &[TextRun]) -> impl Iterator<Item = (usize, char, &CharStyle)> {
|
||||
runs.iter()
|
||||
@@ -503,7 +565,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
|
||||
face.as_ref().map(|f| f.covers(c))
|
||||
};
|
||||
// Byte ranges that keep a font (family, style).
|
||||
// Byte ranges that keep a font (family, style, version and Variable Font settings).
|
||||
let font = |st: &CharStyle| (st.font_family.clone(), st.font_style.clone(), st.font_version.clone(), st.font_axes.clone());
|
||||
let mut keep: Vec<(usize, usize, &CharStyle)> = vec![];
|
||||
for ((at, c, old), (_, _, new)) in chars(before).zip(chars(runs)) {
|
||||
let changed = (&old.font_family, &old.font_style) != (&new.font_family, &new.font_style);
|
||||
@@ -512,7 +575,7 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
}
|
||||
let end = at + c.len_utf8();
|
||||
match keep.last_mut() {
|
||||
Some((_, e, st)) if *e == at && (&st.font_family, &st.font_style) == (&old.font_family, &old.font_style) => *e = end,
|
||||
Some((_, e, st)) if *e == at && font(st) == font(old) => *e = end,
|
||||
_ => keep.push((at, end, old)),
|
||||
}
|
||||
}
|
||||
@@ -520,6 +583,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
edit::style_range(runs, a, b, |st| {
|
||||
st.font_family.clone_from(&old.font_family);
|
||||
st.font_style.clone_from(&old.font_style);
|
||||
st.font_version.clone_from(&old.font_version);
|
||||
st.font_axes.clone_from(&old.font_axes);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ macro_rules! style_cmds {
|
||||
concat!($p, ".new"),
|
||||
concat!("New ", $what, " Style"),
|
||||
&["Window", "Type", $menu],
|
||||
"{name?, attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
|
||||
"{name? (trimmed, as rename trims `to`), attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
|
||||
|s, p| new(s, p, $kind),
|
||||
true,
|
||||
),
|
||||
@@ -141,6 +141,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
for (k, x) in [
|
||||
("font_version", json!(v.font_version)),
|
||||
("fontAxes", json!(v.font_axes)),
|
||||
("leading", json!(v.leading)),
|
||||
("kerning", json!(v.kerning)),
|
||||
("features", json!(v.features)),
|
||||
@@ -176,6 +177,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
("leading_model", json!(v.leading_model)),
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
("roman_hanging", json!(v.roman_hanging)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
}
|
||||
@@ -250,6 +252,21 @@ fn restyle<T: Serialize + DeserializeOwned + Clone>(
|
||||
serde_json::from_value(Value::Object(m)).unwrap_or_else(|_| cur.clone())
|
||||
}
|
||||
|
||||
/// `st`, written over `before` by a character style's `attrs`: when its font or style changed,
|
||||
/// it keeps none of the old one's versions or Variable Font settings unless the style sets them.
|
||||
fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map<String, Value>) -> CharStyle {
|
||||
if st.font_family == before.font_family && st.font_style == before.font_style {
|
||||
return st;
|
||||
}
|
||||
if st.font_version.as_ref().is_some_and(|v| attrs.get("font_version") != Some(&json!(v))) {
|
||||
st.font_version = None;
|
||||
}
|
||||
if !st.font_axes.is_empty() && attrs.get("fontAxes") != Some(&json!(st.font_axes)) {
|
||||
st.font_axes.clear();
|
||||
}
|
||||
st
|
||||
}
|
||||
|
||||
/// Every text object id in the document.
|
||||
fn all_text(d: &Document) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
@@ -293,7 +310,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
|
||||
Kind::Char => {
|
||||
for r in &mut t.runs {
|
||||
if r.style.style_name == target {
|
||||
r.style = restyle(&r.style, char_attrs, old, new);
|
||||
r.style = font_change(&r.style, restyle(&r.style, char_attrs, old, new), new);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -415,10 +432,13 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
|
||||
let name = match str_param(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
Some(n) => n.trim().to_string(),
|
||||
None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string()),
|
||||
};
|
||||
if name.trim().is_empty() || kind.attrs(d, &name).is_some() {
|
||||
if name.is_empty() {
|
||||
return Err(bad(&c, "give the style a name"));
|
||||
}
|
||||
if kind.attrs(d, &name).is_some() {
|
||||
return Err(bad(&c, format!("a style named `{name}` already exists")));
|
||||
}
|
||||
let n2 = name.clone();
|
||||
@@ -447,7 +467,7 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
// Characters: Normal then the style over the defaults (clear) or over the current attributes.
|
||||
let char_fn = |cur: &CharStyle| -> Result<CharStyle> {
|
||||
let base = if clear { with_attrs(&CharStyle::default(), &normal, &c)? } else { cur.clone() };
|
||||
let mut st = with_attrs(&base, &attrs, &c)?;
|
||||
let mut st = font_change(&base, with_attrs(&base, &attrs, &c)?, &attrs);
|
||||
st.style_name = style_name.clone();
|
||||
Ok(st)
|
||||
};
|
||||
@@ -535,10 +555,12 @@ fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let name = name_param(p, kind, "duplicate")?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&kind.cmd("duplicate"), format!("no style `{name}`")))?;
|
||||
// `new` trims the name, so the copy is named from the trimmed one.
|
||||
let stem = name.trim();
|
||||
let copy = (1..)
|
||||
.map(|i| if i == 1 { format!("{name} copy") } else { format!("{name} copy {i}") })
|
||||
.map(|i| if i == 1 { format!("{stem} copy") } else { format!("{stem} copy {i}") })
|
||||
.find(|n| kind.attrs(d, n).is_none())
|
||||
.unwrap_or_else(|| format!("{name} copy"));
|
||||
.unwrap_or_else(|| format!("{stem} copy"));
|
||||
new(s, &json!({ "name": copy, "attrs": attrs }), kind)
|
||||
}
|
||||
|
||||
@@ -671,6 +693,29 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill);
|
||||
}
|
||||
|
||||
/// New Character Style and New Paragraph Style trim the name they are given, as a rename
|
||||
/// trims `to`; the trimmed name is the one compared with the styles there are.
|
||||
#[test]
|
||||
fn new_styles_trim_their_names_as_a_rename_does() {
|
||||
let (mut s, _) = session_with_text("Body text");
|
||||
for p in ["charStyle", "paraStyle"] {
|
||||
assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}");
|
||||
let list = s.execute(&format!("{p}.list"), &json!({})).unwrap();
|
||||
let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect();
|
||||
assert_eq!(names[1..], ["Body"], "{p}");
|
||||
let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string();
|
||||
assert!(e.contains("a style named `Body` already exists"), "{p}: {e}");
|
||||
let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string();
|
||||
assert!(e.contains("give the style a name"), "{p}: {e}");
|
||||
assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}");
|
||||
}
|
||||
// A style whose name has spaces around it, as a saved file can hold, is copied under the
|
||||
// trimmed name, numbered past the copies there are.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() });
|
||||
s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap();
|
||||
assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2");
|
||||
}
|
||||
|
||||
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
|
||||
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
|
||||
/// text at the default, is duplicated and comes back out of a library.
|
||||
@@ -827,6 +872,72 @@ mod tests {
|
||||
assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"}));
|
||||
}
|
||||
|
||||
/// A style captured from type without Variable Font settings sets none explicitly (`{}`):
|
||||
/// applying it clears type's settings, and redefining it from type whose settings were reset
|
||||
/// resets the type that uses it.
|
||||
#[test]
|
||||
fn variable_font_settings_are_captured_explicitly() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let (mut s, source) = session_with_text("Source");
|
||||
let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap();
|
||||
let ids = [source, linked];
|
||||
s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({})));
|
||||
s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
|
||||
let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied());
|
||||
assert_eq!(wght(&s), [None, None], "applying the style clears the settings");
|
||||
for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] {
|
||||
s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap();
|
||||
s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
|
||||
assert_eq!(wght(&s), [want, want], "redefined from {set}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A style that changes the font, applied or redefined, leaves none of the old font's
|
||||
/// version and Variable Font settings, unless the style sets them.
|
||||
#[test]
|
||||
fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let (mut s, id) = session_with_text("Source");
|
||||
let set_variable = |s: &mut Session| {
|
||||
s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap();
|
||||
s.edit("version", |d, _| {
|
||||
if let Some(t) = text_mut(d, NodeId(id)) {
|
||||
t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
let font = |s: &Session| {
|
||||
let st = runs(s, id)[0].style.clone();
|
||||
(st.font_family, st.font_version, st.font_axes.get("wght").copied())
|
||||
};
|
||||
set_variable(&mut s);
|
||||
s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap();
|
||||
s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap();
|
||||
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them");
|
||||
s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap();
|
||||
assert_eq!(font(&s), ("Inter".to_string(), None, None));
|
||||
// A style that sets the settings keeps its own.
|
||||
s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap();
|
||||
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0)));
|
||||
// Redefined to another font: its users' settings and versions go too.
|
||||
s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap();
|
||||
s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap();
|
||||
set_variable(&mut s);
|
||||
assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans"));
|
||||
s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap();
|
||||
assert_eq!(font(&s), ("Inter".to_string(), None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_leading_new_distinguishes_unset_attributes() {
|
||||
let (mut s, id) = session_with_text("Title");
|
||||
|
||||
@@ -21,6 +21,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_multi,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.add",
|
||||
"Thread Text",
|
||||
[],
|
||||
None,
|
||||
"{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type `from` (a click on its out port) into one more frame, put after it in its thread: closed path or area type `to`, a new rectangle `rect`, or a new frame of `from`'s shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.releaseSelection",
|
||||
"Release Selection",
|
||||
@@ -128,6 +137,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None };
|
||||
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
|
||||
let refs: Vec<&TextObject> = frames.iter().collect();
|
||||
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, ¶s);
|
||||
@@ -136,10 +146,12 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
let mut next = (**t).clone();
|
||||
next.runs = part.runs;
|
||||
next.set_paragraph_styles(part.paras);
|
||||
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
|
||||
let optical = optical.unwrap_or(t.optical_margin);
|
||||
if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin {
|
||||
t.runs = next.runs;
|
||||
t.para = next.para;
|
||||
t.paras = next.paras;
|
||||
t.optical_margin = optical;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -151,6 +163,44 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
doc.text_threads = kept;
|
||||
}
|
||||
|
||||
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
|
||||
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
|
||||
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
|
||||
let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin);
|
||||
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id)));
|
||||
changed.or(live.first()).and_then(now)
|
||||
}
|
||||
|
||||
/// Give every frame of each thread the first frame's Optical Margin Alignment setting, without
|
||||
/// re-flowing the story: for a document restored from a file as it was saved.
|
||||
pub(crate) fn share_optical_margin(doc: &mut Document) {
|
||||
for thread in doc.text_threads.clone() {
|
||||
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
|
||||
let Some(on) = optical_margin(doc, doc, &live) else { continue };
|
||||
for id in &live {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
||||
&& t.optical_margin != on
|
||||
{
|
||||
t.optical_margin = on;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
|
||||
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = ids.to_vec();
|
||||
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
|
||||
for id in thread {
|
||||
if !out.contains(id) && text_of(d, *id).is_some() {
|
||||
out.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A closed path converted to an empty area-type frame with `style`.
|
||||
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option<Node> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { return None };
|
||||
@@ -205,6 +255,92 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// `text.thread.add`: the out port's loaded text, placed.
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.thread.add";
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type"))?;
|
||||
let style = src.first_style();
|
||||
// The frame to thread into: an existing object (`to`), or a new one beside `from`.
|
||||
let (to, new) = match (given("to"), given("rect"), given("at")) {
|
||||
(Some(v), None, None) => {
|
||||
let to = checked_id(s, v, C)?;
|
||||
let parent = d.parent_of(to).and_then(|i| d.node(i));
|
||||
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some());
|
||||
if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {
|
||||
return Err(bad(C, "`to` must be a closed path or area type other than `from`"));
|
||||
}
|
||||
if d.text_threads.iter().any(|t| t.contains(&to)) {
|
||||
return Err(bad(C, "`to` is already threaded"));
|
||||
}
|
||||
(to, None)
|
||||
}
|
||||
(None, Some(v), None) => {
|
||||
let [x, y, w, h] = finite_numbers(v)
|
||||
.filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0)
|
||||
.ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?;
|
||||
let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h));
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame, vectorcraft_geom::Affine::translate((x, y)))))
|
||||
}
|
||||
(None, None, Some(v)) => {
|
||||
let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
||||
let TextKind::Area { frame } = &src.kind else { return Err(bad(C, "`from` must be area type")) };
|
||||
let b = frame.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?;
|
||||
let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf;
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame.clone(), xf)))
|
||||
}
|
||||
_ => return Err(bad(C, "give one of `to`, `rect` or `at`")),
|
||||
};
|
||||
let appearance = d.node(from).map(|n| n.appearance.clone());
|
||||
let id = s.edit("Thread Text", |d, sel| {
|
||||
let to = match new {
|
||||
Some(t) => {
|
||||
let (parent, idx, _) = d.position(from).ok_or(EngineError::NoNode(from))?;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
|
||||
if let Some(a) = appearance {
|
||||
n.appearance = a;
|
||||
}
|
||||
d.insert(parent, idx + 1, n)?;
|
||||
id
|
||||
}
|
||||
None => {
|
||||
if text_of(d, to).is_none() {
|
||||
let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?;
|
||||
*d.node_mut(to).ok_or(EngineError::NoNode(to))? = frame_from_path(&n, style).ok_or_else(|| bad(C, "not a closed path"))?;
|
||||
}
|
||||
to
|
||||
}
|
||||
};
|
||||
match d.text_threads.iter_mut().find(|t| t.contains(&from)) {
|
||||
Some(t) => {
|
||||
let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1);
|
||||
t.insert(at, to);
|
||||
}
|
||||
None => d.text_threads.push(vec![from, to]),
|
||||
}
|
||||
// Force a re-flow of the thread on this edit: the edit leaves existing area type `to`
|
||||
// unchanged.
|
||||
touch(d, to);
|
||||
sel.set([from, to]);
|
||||
Ok(to)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// An empty area-type frame `frame` placed by `xf`, with the type options of `src`.
|
||||
fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, frame: vectorcraft_geom::PathData, xf: vectorcraft_geom::Affine) -> TextObject {
|
||||
let mut t = src.clone();
|
||||
t.runs = vec![TextRun { text: String::new(), style, inline: None }];
|
||||
t.set_all_paras(src.para_at(0).clone());
|
||||
t.kind = TextKind::Area { frame };
|
||||
t.xf = xf;
|
||||
refresh_bounds(&mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// Give a node a fresh allocation so [`reflow`] sees its thread as changed.
|
||||
fn touch(d: &mut Document, id: NodeId) {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
@@ -413,6 +549,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_id(v: Value) -> u64 {
|
||||
v["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn threads(s: &Session) -> Vec<Vec<u64>> {
|
||||
s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect()
|
||||
}
|
||||
|
||||
/// Bounds of area type `id`'s frame on the page.
|
||||
fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
|
||||
t.xf.transform_rect_bbox(frame.bounds().unwrap())
|
||||
}
|
||||
|
||||
/// #2572: the text loaded from an out port threads into a closed path, into a new frame of the
|
||||
/// first one's shape and size at a point, and into a new rectangle, each in one undo step.
|
||||
#[test]
|
||||
fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() {
|
||||
let (mut s, a, b) = setup();
|
||||
assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b);
|
||||
assert_eq!(threads(&s), [[a, b]]);
|
||||
assert!(!text(&s, b).is_empty(), "the overflow flowed into the path");
|
||||
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back");
|
||||
|
||||
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap());
|
||||
assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click");
|
||||
let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap());
|
||||
assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0));
|
||||
assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from");
|
||||
assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4));
|
||||
assert!(!text(&s, d).is_empty());
|
||||
// A frame threaded from the middle of a thread goes in after it.
|
||||
let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap());
|
||||
assert_eq!(threads(&s), [[a, e, c, d]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none());
|
||||
assert_eq!(threads(&s), [[a, c, d]]);
|
||||
}
|
||||
|
||||
/// Area type threaded into from an out port takes the overflow at once, before its own text.
|
||||
#[test]
|
||||
fn add_into_area_type_flows_the_story_into_it() {
|
||||
let (mut s, a, _) = setup();
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
assert_eq!(threads(&s), [[a, c]]);
|
||||
assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4)));
|
||||
assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_refuses_bad_params_and_leaves_the_document_alone() {
|
||||
let (mut s, a, b) = setup();
|
||||
let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for p in [
|
||||
json!({"to": b}),
|
||||
json!({"from": b, "at": [0, 0]}),
|
||||
json!({"from": 9999, "at": [0, 0]}),
|
||||
json!({"from": -1, "at": [0, 0]}),
|
||||
json!({"from": a}),
|
||||
json!({"from": a, "to": b, "at": [0, 0]}),
|
||||
json!({"from": a, "to": a}),
|
||||
json!({"from": a, "to": line}),
|
||||
json!({"from": a, "to": 9999}),
|
||||
json!({"from": a, "at": [0]}),
|
||||
json!({"from": a, "at": ["1", 2]}),
|
||||
json!({"from": a, "rect": [0, 0, -10, 10]}),
|
||||
json!({"from": a, "rect": [0, 0, 10, 0]}),
|
||||
json!({"from": a, "rect": [0, 0, 10]}),
|
||||
json!({"from": a, "rect": "big"}),
|
||||
] {
|
||||
assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document");
|
||||
}
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap());
|
||||
assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_and_remove() {
|
||||
let (mut s, a, b) = setup();
|
||||
@@ -428,4 +649,113 @@ mod tests {
|
||||
assert_eq!(text(&s, b), "");
|
||||
assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame");
|
||||
}
|
||||
|
||||
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
|
||||
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
|
||||
fn quoted_thread() -> (Session, u64, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let story = "“Quoted.”\n".repeat(20);
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert!(text(&s, b).starts_with('“'));
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
/// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame
|
||||
/// (its lines' opening quotation marks hang).
|
||||
fn optical(s: &Session, id: u64) -> (bool, bool) {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
|
||||
(t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0)
|
||||
}
|
||||
|
||||
/// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment
|
||||
/// in any frame sets it in every frame of the thread, in one undo step, and the lines in every
|
||||
/// frame hang. Typing keeps it, and frames taken out of the thread keep it.
|
||||
#[test]
|
||||
fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() {
|
||||
let (mut s, a, b) = quoted_thread();
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"on": true, "ids": [a, b]}));
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too");
|
||||
// Turned off from the second frame, it is off in both, and one undo turns it back on in both.
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false);
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
|
||||
// Typing in the first frame re-flows the story and keeps the setting.
|
||||
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap();
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
|
||||
// Any edit that sets one frame sets the thread.
|
||||
s.edit("Set", |d, _| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
t.optical_margin = false;
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Released or unthreaded, each frame keeps it.
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
|
||||
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("text.thread.remove", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
|
||||
}
|
||||
|
||||
/// Threading type into a thread gives the joined thread the setting of its first frame.
|
||||
#[test]
|
||||
fn a_thread_takes_its_first_frames_optical_margin_alignment() {
|
||||
let (mut s, a, b) = quoted_thread();
|
||||
s.execute("text.thread.remove", &json!({})).unwrap();
|
||||
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
|
||||
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
|
||||
// Threaded again in stacking order: the second frame takes the first one's setting.
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
|
||||
// From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting.
|
||||
s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap();
|
||||
assert_eq!(threads(&s), [[b, a]]);
|
||||
assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false));
|
||||
// A new frame threaded from a frame takes the thread's setting.
|
||||
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
|
||||
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap());
|
||||
assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]);
|
||||
}
|
||||
|
||||
/// A file saved with the frames of one thread set differently opens with the first frame's
|
||||
/// setting in every frame: a native file, and an EPS file restored from the document it carries.
|
||||
#[test]
|
||||
fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() {
|
||||
let (mut s, a, b) = quoted_thread();
|
||||
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
|
||||
for (id, on) in [(a, first), (b, !first)] {
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
t.optical_margin = on;
|
||||
}
|
||||
let bytes = if name.ends_with(".eps") {
|
||||
let r = s.execute("document.exportEps", &json!({})).unwrap();
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
|
||||
} else {
|
||||
vectorcraft_format::save(&s.doc().unwrap().doc, false)
|
||||
};
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
|
||||
assert_eq!(threads(&s), [[a, b]], "{name}");
|
||||
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Optical Margin Alignment",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
|
||||
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
|
||||
has_selection,
|
||||
optical_margin_alignment
|
||||
),
|
||||
@@ -107,9 +107,9 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's
|
||||
/// setting) on every selected text object, in one undo step.
|
||||
/// setting) on every selected text object and every frame of their threads, in one undo step.
|
||||
fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "Optical Margin Alignment")?;
|
||||
let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Optical Margin Alignment")?);
|
||||
let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.optical_margin),
|
||||
_ => None,
|
||||
@@ -577,9 +577,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = &style {
|
||||
st.font_style = f.clone();
|
||||
}
|
||||
// Another font: none of the old one's versions.
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
if font.is_some() || style.is_some() {
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v;
|
||||
@@ -613,10 +614,12 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = &font {
|
||||
st.font_family = f.clone();
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(f) = &style {
|
||||
st.font_style = f.clone();
|
||||
st.font_version = None;
|
||||
st.font_axes.clear();
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
@@ -1058,6 +1061,43 @@ mod area_tests {
|
||||
assert_eq!(old.hyphenation, HyphenationOptions::default());
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}`
|
||||
/// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the
|
||||
/// frame, it is saved only when on, and paragraph styles carry it.
|
||||
#[test]
|
||||
fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted”\n“Two”", "area": {"width": 300, "height": 200}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let left = |s: &Session| text(s).cached_bounds.unwrap().x0;
|
||||
assert!(!text(&s).para_at(0).roman_hanging);
|
||||
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none());
|
||||
let inside = left(&s);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
// Only the first paragraph (the caret's range).
|
||||
s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap();
|
||||
assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging);
|
||||
assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true);
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
|
||||
assert!(back.roman_hanging);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!text(&s).para_at(0).roman_hanging);
|
||||
assert!((left(&s) - inside).abs() < 1e-9);
|
||||
// A paragraph style carries it.
|
||||
s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap();
|
||||
assert!(text(&s).para_at(1).roman_hanging);
|
||||
}
|
||||
|
||||
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
|
||||
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
|
||||
/// carry them; params override; the values are journaled; imported text keeps the Roman
|
||||
@@ -1248,6 +1288,80 @@ mod area_tests {
|
||||
assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]);
|
||||
}
|
||||
|
||||
/// Variable Font settings (Character panel › Variable Font): a character attribute, saved only
|
||||
/// when set, each value clamped to its axis's range, and cleared by another font or style.
|
||||
#[test]
|
||||
fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font};
|
||||
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned();
|
||||
let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width();
|
||||
assert_eq!(saved(&s), None);
|
||||
let regular = width(&s);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wght": 650.0})));
|
||||
assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s));
|
||||
// Past the axis's range: its maximum; an axis the font hasn't: left out; others kept.
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0})));
|
||||
// Null goes back to the style's own setting, and so does a value equal to it.
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap();
|
||||
assert_eq!(saved(&s), None);
|
||||
assert!((width(&s) - regular).abs() < 1e-6);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
|
||||
// Characters selected with the Type tool: only the range takes it.
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap();
|
||||
let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect();
|
||||
let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]);
|
||||
// A saved file keeps them.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.runs, runs(&s));
|
||||
// Another style or font clears them: their settings were the old face's.
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap();
|
||||
assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true)));
|
||||
s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap();
|
||||
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
// A static font has no axes to set.
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
assert_eq!(saved(&s), None);
|
||||
// A missing font keeps them as given, for when it is installed.
|
||||
s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap();
|
||||
assert_eq!(saved(&s), Some(json!({"wght": 2000.0})));
|
||||
for (bad, says) in
|
||||
[(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")]
|
||||
{
|
||||
let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string();
|
||||
assert!(err.contains(says), "{err}");
|
||||
}
|
||||
// The axes and the setting of a style, as the sliders show them.
|
||||
let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap();
|
||||
assert_eq!(
|
||||
list["axes"],
|
||||
json!([
|
||||
{"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false},
|
||||
{"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false},
|
||||
])
|
||||
);
|
||||
assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! variable, or a graph root to a `graph` variable (CSV data), put a row of values in
|
||||
//! each dataset, and `dataset.select` swaps the whole document to that row in one undo
|
||||
//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name`
|
||||
//! then variable names); import also reads tab-separated files and files without the
|
||||
//! `name` column. Agents reach every command through `run_command`, like all engine
|
||||
//! then variable names); import also reads tab-separated files, files without the
|
||||
//! `name` column, and header cells that define new variables (`#` for visibility, `@` for
|
||||
//! image, no prefix for text). Agents reach every command through `run_command`, like all engine
|
||||
//! commands.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -29,8 +30,9 @@ const MAX_CSV_BYTES: usize = 1_048_576;
|
||||
const MAX_CSV_ROWS: usize = 1_000;
|
||||
const MAX_CSV_COLS: usize = 256;
|
||||
|
||||
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options) and
|
||||
/// Make … Dynamic; binding, unbinding and the dataset commands still run.
|
||||
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options),
|
||||
/// Make … Dynamic and an import whose header would define a variable; binding, unbinding and the
|
||||
/// other dataset commands still run.
|
||||
fn variables_unlocked(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) }
|
||||
@@ -710,10 +712,52 @@ fn datasets_to_csv(vars: &Variables) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The variable that a datasets CSV header cell (trimmed, not empty) in 1-based `column` names,
|
||||
/// and its kind. A cell that is the name of a variable in `kinds` or in `defined` (the variables
|
||||
/// that earlier columns define) names that variable. Otherwise `#` before the name makes it a
|
||||
/// visibility variable, `@` makes it an image variable, and a cell without a prefix names a text
|
||||
/// variable. A prefixed name that is already a variable must be one of the prefix's kind; a new
|
||||
/// name is added to `defined`. A `%` cell that names no variable (graph data files) is refused.
|
||||
fn header_variable(
|
||||
cell: &str,
|
||||
column: usize,
|
||||
kinds: &BTreeMap<String, VariableKind>,
|
||||
defined: &mut Vec<Variable>,
|
||||
cmd: &str,
|
||||
) -> Result<(String, VariableKind)> {
|
||||
let known = |name: &str, defined: &[Variable]| kinds.get(name).copied().or_else(|| defined.iter().find(|v| v.name == name).map(|v| v.kind));
|
||||
if let Some(kind) = known(cell, defined) {
|
||||
return Ok((cell.to_string(), kind));
|
||||
}
|
||||
let (name, kind) = if let Some(rest) = cell.strip_prefix('#') {
|
||||
(rest.trim(), VariableKind::Visibility)
|
||||
} else if let Some(rest) = cell.strip_prefix('@') {
|
||||
(rest.trim(), VariableKind::Image)
|
||||
} else if cell.starts_with('%') {
|
||||
return Err(bad(cmd, format!("column {column}: `{cell}` is a `%` column of graph data files, which are not read")));
|
||||
} else {
|
||||
(cell, VariableKind::Text)
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, format!("column {column}: `{cell}` names no variable")));
|
||||
}
|
||||
match known(name, defined) {
|
||||
Some(k) if k == kind => Ok((name.to_string(), kind)),
|
||||
Some(k) => Err(bad(cmd, format!("column {column}: `{cell}` names `{name}`, which is a {} variable", k.label()))),
|
||||
None if name.chars().count() > MAX_NAME => Err(bad(cmd, format!("column {column}: the variable name is longer than {MAX_NAME} characters"))),
|
||||
None => {
|
||||
defined.push(Variable { name: name.to_string(), kind });
|
||||
Ok((name.to_string(), kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Import datasets from CSV. A first header cell `name` makes the first column the dataset
|
||||
/// names; the other headers are variable names. Without it, every header is a variable name
|
||||
/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows
|
||||
/// of the same name are replaced; new names are appended. One undo step.
|
||||
/// names; the other headers name variables, as [`header_variable`] reads them, and the
|
||||
/// variables the document doesn't define yet are defined, unbound. Without the `name` column,
|
||||
/// every header names a variable and the rows take the first free names "Data Set 1", "Data
|
||||
/// Set 2" and so on. Existing rows of the same name are replaced; new names are appended. One
|
||||
/// undo step.
|
||||
fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "dataset.import";
|
||||
let bytes = datasets_csv_bytes(p, C)?;
|
||||
@@ -730,13 +774,18 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(kinds, existing)
|
||||
};
|
||||
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
|
||||
let mut defined: Vec<Variable> = Vec::new();
|
||||
for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) {
|
||||
let name = h.trim();
|
||||
if name.is_empty() {
|
||||
let cell = h.trim();
|
||||
if cell.is_empty() {
|
||||
return Err(bad(C, format!("empty header in column {}", i + 1)));
|
||||
}
|
||||
let kind = kinds.get(name).copied().ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
|
||||
columns.push((i, name.to_string(), kind));
|
||||
let (name, kind) = header_variable(cell, i + 1, &kinds, &mut defined, C)?;
|
||||
columns.push((i, name, kind));
|
||||
}
|
||||
// Lock Variables refuses New Variable, so the import is refused when a header would define one.
|
||||
if !defined.is_empty() {
|
||||
variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?;
|
||||
}
|
||||
if rows.len() == 1 {
|
||||
return Err(bad(C, "CSV has a header but no data rows"));
|
||||
@@ -777,7 +826,9 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
parsed.push(DataSet { name, values });
|
||||
}
|
||||
let names: Vec<String> = parsed.iter().map(|d| d.name.clone()).collect();
|
||||
let new_variables: Vec<String> = defined.iter().map(|v| v.name.clone()).collect();
|
||||
let (imported, updated) = s.edit("Import Data Sets", |d, _| {
|
||||
d.variables.variables.extend(defined);
|
||||
let mut imported = 0usize;
|
||||
let mut updated = 0usize;
|
||||
for row in parsed {
|
||||
@@ -791,7 +842,7 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((imported, updated))
|
||||
})?;
|
||||
Ok(json!({"imported": imported, "updated": updated, "names": names}))
|
||||
Ok(json!({"imported": imported, "updated": updated, "names": names, "defined": new_variables}))
|
||||
}
|
||||
|
||||
/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`.
|
||||
@@ -1003,7 +1054,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Data Sets…",
|
||||
["Window", "Variables"],
|
||||
None,
|
||||
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}",
|
||||
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so is a header that would define a variable while the variables are locked. Rows whose name already exists are replaced → {imported, updated, names, defined}",
|
||||
has_doc,
|
||||
dataset_import
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::select::selectable_ids;
|
||||
use super::*;
|
||||
|
||||
/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in
|
||||
@@ -153,7 +154,13 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
|
||||
let w = s.menu.wand.clone();
|
||||
let hits: Vec<NodeId> = paintable(&st.doc).into_iter().filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n))).collect();
|
||||
// In isolation mode only the isolated group's contents can be picked, as for Select Same.
|
||||
let inside = st.isolation.map(|iso| selectable_ids(&st.doc, Some(iso)));
|
||||
let hits: Vec<NodeId> = paintable(&st.doc)
|
||||
.into_iter()
|
||||
.filter(|i| inside.as_ref().is_none_or(|set| set.contains(i)))
|
||||
.filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n)))
|
||||
.collect();
|
||||
let ids: Vec<NodeId> = match str_param(p, "mode").unwrap_or("set") {
|
||||
"add" => st.selection.objects.iter().copied().chain(hits.iter().copied().filter(|h| !st.selection.objects.contains(h))).collect(),
|
||||
"subtract" => st.selection.objects.iter().copied().filter(|i| !hits.contains(i)).collect(),
|
||||
@@ -222,4 +229,19 @@ mod tests {
|
||||
s.execute("select.magicWand", &json!({"id": b})).unwrap();
|
||||
assert_eq!(selected(&s), [b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolation_mode_limits_the_wand_to_the_isolated_group() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||||
let a = rect(&mut s, 0.0, "#ff0000", 1.0);
|
||||
let b = rect(&mut s, 20.0, "#ff0000", 1.0);
|
||||
let outside = rect(&mut s, 40.0, "#ff0000", 1.0);
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.isolate", &json!({"id": g})).unwrap();
|
||||
s.execute("select.magicWand", &json!({"id": a})).unwrap();
|
||||
assert_eq!(selected(&s), [a, b]);
|
||||
assert!(!selected(&s).contains(&outside));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save for Web",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height}; no path → {files: [{name, dataBase64}], bytes, width, height} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
|
||||
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height, warnings: [] or [a note that an image couldn't be decoded and is missing from the image]}; no path → {files: [{name, dataBase64}], bytes, width, height, warnings} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save for Web Preview",
|
||||
[],
|
||||
None,
|
||||
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, dataBase64?}",
|
||||
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, warnings (as document.exportForWeb's), dataBase64?}",
|
||||
has_doc,
|
||||
preview
|
||||
),
|
||||
@@ -873,9 +873,11 @@ pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem:
|
||||
continue;
|
||||
}
|
||||
let base: String = doc.slice_name(c.area).chars().map(|ch| if ch.is_alphanumeric() || "-_.".contains(ch) { ch } else { '-' }).collect();
|
||||
let base = unique_name(&base, |n| taken.contains(&n.to_lowercase()));
|
||||
taken.insert(base.to_lowercase());
|
||||
let name = format!("{IMAGES}/{base}.{ext}");
|
||||
// A slice named after a Windows device (`aux`, `NUL`…) gets a `-` after the device name.
|
||||
let file = |base: &str| super::fileio::safe_file_name(&format!("{base}.{ext}"));
|
||||
let base = unique_name(&base, |n| taken.contains(&file(n).to_lowercase()));
|
||||
taken.insert(file(&base).to_lowercase());
|
||||
let name = format!("{IMAGES}/{}", file(&base));
|
||||
let [x0, y0, x1, y1] = c.px;
|
||||
images.push(WebFile { name: name.clone(), bytes: optimize(doc, &crop(&r, c.px), x1 - x0, y1 - y0, s)?.bytes });
|
||||
placed.push((Some(c), Some(name)));
|
||||
@@ -967,14 +969,17 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").filter(|p| !p.trim().is_empty());
|
||||
let stem = match path {
|
||||
Some(p) => super::fileio::file_stem(p),
|
||||
None => super::fileio::file_stem(&st.doc.title),
|
||||
// Without a path the files are named after the title, following Windows' file-naming rules.
|
||||
None => super::fileio::safe_file_name(&super::fileio::file_stem(&st.doc.title)),
|
||||
};
|
||||
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let (files, (width, height)) = export_files(&st.doc, &selected, &settings, &stem).map_err(|e| bad(C, e))?;
|
||||
let warnings = undecoded(failed);
|
||||
let total: usize = files.iter().map(|f| f.bytes.len()).sum();
|
||||
let Some(path) = path else {
|
||||
let rows: Vec<Value> = files.iter().map(|f| json!({ "name": f.name, "dataBase64": vectorcraft_format::base64_encode(&f.bytes) })).collect();
|
||||
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height }));
|
||||
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height, "warnings": warnings }));
|
||||
};
|
||||
// Siblings keep the path's own separators.
|
||||
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
|
||||
@@ -988,7 +993,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::fileio::write_file(&target, &f.bytes)?;
|
||||
written.push(target);
|
||||
}
|
||||
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height }))
|
||||
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height, "warnings": warnings }))
|
||||
}
|
||||
|
||||
/// The warnings of a rendering that started when this thread's decode failure count was `failed`:
|
||||
/// the note the raster exports add (`fileio::encode::encode_all_of`) when an image has failed to
|
||||
/// decode since then and so is missing from the image.
|
||||
fn undecoded(failed: u64) -> Vec<&'static str> {
|
||||
super::rasterfx::undecoded_warning(failed).into_iter().collect()
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1000,7 +1012,9 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let r = render(doc, &settings).map_err(|e| bad(P, e))?;
|
||||
let warnings = undecoded(failed);
|
||||
let areas = doc.slice_layout();
|
||||
let selected = super::slices::selected_slices(doc, &st.selection);
|
||||
let cells = cells(&areas, &selected, &settings, &r).map_err(|e| bad(P, e))?;
|
||||
@@ -1016,7 +1030,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map_err(|e| bad(P, e))?;
|
||||
let mut out = json!({
|
||||
"format": settings.format.id(), "width": o.width, "height": o.height, "bytes": o.bytes.len(),
|
||||
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images,
|
||||
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images, "warnings": warnings,
|
||||
});
|
||||
if settings.format.palette() {
|
||||
out["colors"] = o
|
||||
|
||||
@@ -918,7 +918,8 @@ pub struct Prefs {
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
pub render_threads: i32,
|
||||
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
|
||||
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
|
||||
/// XWayland on a Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it
|
||||
/// through XWayland only when a pen is attached (#2028). Read when the app starts.
|
||||
pub linux_display_server: String,
|
||||
// File Handling
|
||||
pub background_save: bool,
|
||||
@@ -2101,6 +2102,8 @@ mod tests_legacy_ai;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
#[cfg(test)]
|
||||
mod tests_link_paths;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_linkfiles;
|
||||
|
||||
@@ -30,15 +30,19 @@ pub type Stamp = (u64, u128);
|
||||
/// object ids, stamp)]).
|
||||
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
|
||||
|
||||
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
|
||||
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
|
||||
/// automation may not read it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stamp(path: &str) -> Option<Stamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
fn stamp(path: &str, doc_path: Option<&str>) -> Option<Stamp> {
|
||||
let path = crate::cmd::fileio::document_path(path, doc_path).ok()?;
|
||||
let meta = crate::cmd::fileio::file_metadata(&path)?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((meta.len(), modified.as_nanos()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn stamp(_: &str) -> Option<Stamp> {
|
||||
fn stamp(_: &str, _: Option<&str>) -> Option<Stamp> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -52,7 +56,7 @@ pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
|
||||
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, st.path.as_deref()))).collect();
|
||||
s.apply_link_stamps(uid, now, true)
|
||||
}
|
||||
|
||||
@@ -64,14 +68,15 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
let Some(st) = self.active() else { return };
|
||||
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
|
||||
let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone());
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let slot: LinkScan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
|
||||
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
let stamps = scope.run(|| files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect());
|
||||
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
|
||||
@@ -133,22 +133,46 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Write the saved preset `preset` to the store, which held it as `old_name` when that is set
|
||||
/// (a rename). Without a store, nothing happens.
|
||||
/// (a rename). A rename writes the new file first and removes the old one only when that write
|
||||
/// succeeded: after a failed write the store still holds the preset under its old name. When
|
||||
/// the two names differ only in case, the old file is written and then renamed to the new name;
|
||||
/// when that rename fails, the old file gets its content back. Without a store, nothing happens.
|
||||
pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) {
|
||||
let Some(dir) = &self.pdf_preset_dir else { return };
|
||||
let mut clean = preset.clone();
|
||||
clean.settings.clear_passwords();
|
||||
let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name));
|
||||
if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o)))
|
||||
&& old != file
|
||||
{
|
||||
take(dir, &old);
|
||||
}
|
||||
match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
|
||||
Ok(bytes) => {
|
||||
let bytes = match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
log::warn!("can't save the PDF preset `{}`: {e}", preset.name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))).filter(|old| *old != file) {
|
||||
// Names equal in any case get file names that differ only in case. A file system that
|
||||
// ignores case holds them as one file, which removing the old name would delete.
|
||||
Some(old) if old.to_lowercase() == file.to_lowercase() => match read_capped(&dir.join(&old), MAX_PRESET_FILE) {
|
||||
Ok(before) => {
|
||||
if put(dir, &old, &bytes)
|
||||
&& let Err(e) = std::fs::rename(dir.join(&old), dir.join(&file))
|
||||
{
|
||||
log::warn!("can't rename {} to {file}: {e}", dir.join(&old).display());
|
||||
put(dir, &old, &before);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
put(dir, &file, &bytes);
|
||||
}
|
||||
},
|
||||
Some(old) => {
|
||||
if put(dir, &file, &bytes) {
|
||||
take(dir, &old);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
put(dir, &file, &bytes);
|
||||
}
|
||||
Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1708,6 +1708,27 @@ fn direct_selection_snaps_anchors_to_the_grid() {
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Grid on, a bounding-box handle dragged with the Selection tool or the Free
|
||||
/// Transform tool lands the corner it moves on the grid, even with Snap to Pixel left on too; the
|
||||
/// opposite corner stays put.
|
||||
#[test]
|
||||
fn bounding_box_handles_snap_to_the_grid() {
|
||||
for tool in ["selection", "freeTransform"] {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
|
||||
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
|
||||
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
|
||||
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
|
||||
s.select_tool(tool, v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 201.0, 201.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 230.0, 214.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 230.0, 214.0), v).unwrap();
|
||||
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
|
||||
assert!(on_grid(b.x1) && on_grid(b.y1), "{tool}: {b:?}, grid step {step}");
|
||||
assert!((b.x0 - 101.0).abs() < 1e-9 && (b.y0 - 101.0).abs() < 1e-9, "{tool}: {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
|
||||
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
|
||||
#[test]
|
||||
|
||||
@@ -66,6 +66,55 @@ fn a_spine_point_added_and_moved_bends_the_blend() {
|
||||
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6);
|
||||
}
|
||||
|
||||
/// #2544: an Anchor Point drag on a blend spine must create two symmetric
|
||||
/// handles, not bend the adjacent segment and leave one handle behind.
|
||||
#[test]
|
||||
fn spine_anchor_conversion_preserves_keys_and_undo() {
|
||||
let mut s = session();
|
||||
let (a, b, g) = blend(&mut s);
|
||||
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
|
||||
let keys_before = spec(&s, g).key_anchors;
|
||||
let point_before = spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p;
|
||||
assert!(point_before.distance(Point::new(55.0, 5.0)) < 1e-9);
|
||||
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 55, "y": 35})).unwrap();
|
||||
let sp = spec(&s, g);
|
||||
assert_eq!(sp.key_anchors, keys_before);
|
||||
let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1];
|
||||
assert_eq!(middle.kind, vectorcraft_geom::AnchorKind::Smooth);
|
||||
assert_eq!(middle.p, point_before);
|
||||
assert_eq!(middle.h_in, point_before - (Point::new(55.0, 35.0) - point_before));
|
||||
assert_eq!(middle.h_out, Point::new(55.0, 35.0));
|
||||
assert_eq!(center(&node(&s, a)), Point::new(5.0, 5.0));
|
||||
assert_eq!(center(&node(&s, b)), Point::new(105.0, 5.0));
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let before = spec(&s, g);
|
||||
assert_eq!(before.spine.unwrap().subpaths[0].anchors[1].h_out, point_before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle);
|
||||
|
||||
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "corner"})).unwrap();
|
||||
let corner = spec(&s, g).spine.unwrap().subpaths[0].anchors[1];
|
||||
assert!(!corner.has_in() && !corner.has_out());
|
||||
assert_eq!(corner.kind, vectorcraft_geom::AnchorKind::Corner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spine_conversion_rejects_invalid_arguments_without_mutation() {
|
||||
let mut s = session();
|
||||
let (_, _, g) = blend(&mut s);
|
||||
let before = spec(&s, g);
|
||||
for params in [
|
||||
json!({"id": g.0, "anchor": 5, "to": "smooth", "x": 20, "y": 20}),
|
||||
json!({"id": g.0, "anchor": 0, "to": "unknown"}),
|
||||
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": 10}),
|
||||
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": f64::MAX, "y": 1}),
|
||||
] {
|
||||
assert!(s.execute("object.blend.spine.convertAnchor", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(spec(&s, g), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -140,6 +140,32 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() {
|
||||
assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err());
|
||||
}
|
||||
|
||||
/// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options
|
||||
/// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name
|
||||
/// gives the name the command gives when there is none.
|
||||
#[test]
|
||||
fn new_duplicate_and_renamed_brushes_trim_their_names() {
|
||||
let mut s = session();
|
||||
let new = |s: &mut Session, name: &str| s.execute("brush.new", &json!({"type": "calligraphic", "name": name})).unwrap()["name"].clone();
|
||||
assert_eq!(new(&mut s, " Nib \t"), "Nib");
|
||||
assert_eq!(new(&mut s, " Nib"), "Nib 2");
|
||||
assert_eq!(new(&mut s, " \t "), "New Calligraphic Brush");
|
||||
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " Quill\t"})).unwrap()["name"], "Quill");
|
||||
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " "})).unwrap()["name"], "Nib copy");
|
||||
assert_eq!(s.execute("brush.options", &json!({"name": "Nib 2", "newName": " Pen "})).unwrap()["name"], "Pen");
|
||||
assert_eq!(s.execute("brush.options", &json!({"name": "Pen", "newName": " "})).unwrap()["name"], "Pen");
|
||||
let e = s.execute("brush.options", &json!({"name": "Pen", "newName": " Quill"})).unwrap_err().to_string();
|
||||
assert!(e.contains("a brush named `Quill` exists"), "{e}");
|
||||
// Duplicate Brush names the copy of a brush whose saved name has white space around it, as a
|
||||
// saved file can hold, from the trimmed name (" Old " gives "Old copy").
|
||||
let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc);
|
||||
lib.iter_mut().filter(|b| b.name == "Pen").for_each(|b| b.name = " Old ".into());
|
||||
vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib);
|
||||
assert_eq!(s.execute("brush.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy");
|
||||
let names: Vec<String> = vectorcraft_brush::library(&s.doc().unwrap().doc).into_iter().map(|b| b.name).collect();
|
||||
assert_eq!(names[names.len() - 6..], ["Nib", " Old ", "New Calligraphic Brush", "Quill", "Nib copy", "Old copy"]);
|
||||
}
|
||||
|
||||
/// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo
|
||||
/// step, and an unknown name changes nothing.
|
||||
#[test]
|
||||
@@ -541,6 +567,23 @@ fn delete_duplicate_and_replace_symbols() {
|
||||
assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err());
|
||||
}
|
||||
|
||||
/// New Symbol and Duplicate Symbol trim the name they are given, as Symbol Options trims
|
||||
/// `newName`; the trimmed name is the one compared with the symbols there are.
|
||||
#[test]
|
||||
fn new_and_duplicate_symbols_trim_their_names_as_a_rename_does() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
assert_eq!(s.execute("symbol.new", &json!({"name": " Box \t"})).unwrap()["name"], "Box");
|
||||
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
assert_eq!(s.execute("symbol.new", &json!({"name": " Box"})).unwrap()["name"], "Box 2");
|
||||
rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
assert_eq!(s.execute("symbol.new", &json!({"name": " "})).unwrap()["name"], "New Symbol");
|
||||
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " Logo\t"})).unwrap()["name"], "Logo");
|
||||
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " "})).unwrap()["name"], "Box copy");
|
||||
let names: Vec<String> = s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect();
|
||||
assert_eq!(names, ["Box", "Box 2", "New Symbol", "Logo", "Box copy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_export_type_defaults_to_movie_clip_and_round_trips() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -908,6 +908,34 @@ fn traced_paths(n: &Node) -> Vec<Arc<Node>> {
|
||||
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_image_traces_with_a_warning() {
|
||||
// The public documentation: images over 1500 px on a side are traced, only more slowly.
|
||||
let mut s = session();
|
||||
let art = vectorcraft_trace::Raster::from_fn(
|
||||
1600,
|
||||
40,
|
||||
|x, y| if (100..1500).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] },
|
||||
);
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(r["paths"], 1);
|
||||
let w = r["warnings"].as_array().unwrap();
|
||||
assert_eq!(w.len(), 1, "{w:?}");
|
||||
assert!(w[0].as_str().unwrap().contains("1600 × 40") && w[0].as_str().unwrap().contains("1500"), "{w:?}");
|
||||
// Make and Expand says so too.
|
||||
let r = s.execute("imageTrace.makeAndExpand", &json!({"id": r["id"], "params": {"ignoreWhite": true}})).unwrap();
|
||||
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1));
|
||||
// At 1500 px and under, no warning.
|
||||
let mut s = session();
|
||||
image_doc(&mut s);
|
||||
let r = s.execute("imageTrace.make", &json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
let edge = vectorcraft_trace::Raster::from_fn(1500, 8, |x, _| if x < 700 { [0, 0, 0, 255] } else { [255; 4] });
|
||||
add_image(&mut s, &edge, Affine::IDENTITY);
|
||||
assert_eq!(s.execute("imageTrace.make", &json!({})).unwrap()["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_make_places_paths_over_the_image() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -97,6 +97,38 @@ fn exported_pdf_pastes_back_as_vectors() {
|
||||
assert!(t.execute("clipboard.importPdf", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_png_and_pdf_say_when_an_image_or_a_font_is_missing() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let warnings = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
|
||||
let mut s = session();
|
||||
copy_red_square(&mut s);
|
||||
assert_eq!(run(&mut s, "clipboard.exportPng", json!({}))["warnings"], json!([]));
|
||||
assert_eq!(run(&mut s, "clipboard.exportPdf", json!({}))["warnings"], json!([]));
|
||||
// Type in a family that isn't installed: the PNG and the PDF each name it.
|
||||
let text = run(&mut s, "text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [text]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
let png = warnings(&run(&mut s, "clipboard.exportPng", json!({})));
|
||||
assert!(png.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{png:?}");
|
||||
let pdf = warnings(&run(&mut s, "clipboard.exportPdf", json!({})));
|
||||
assert!(pdf.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{pdf:?}");
|
||||
// Copy's formats carry the notes of both, each once.
|
||||
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, true);
|
||||
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [TEXT, PDF, PNG]);
|
||||
for w in png.iter().chain(&pdf) {
|
||||
assert_eq!(notes.iter().filter(|n| *n == w).count(), 1, "{w} in {notes:?}");
|
||||
}
|
||||
// An image the renderer can't decode: the PNG leaves it out and says so.
|
||||
let image = crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [image.0]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(warnings(&run(&mut s, "clipboard.exportPng", json!({}))), [NOTE]);
|
||||
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, false);
|
||||
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [PNG]);
|
||||
assert_eq!(notes, [NOTE]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_image_then_paste_gives_an_image() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -115,3 +115,25 @@ fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
|
||||
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn export_says_once_when_a_rasterized_image_could_not_be_decoded() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let notes = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str() == Some(NOTE)).count();
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
|
||||
assert_eq!(notes(&run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}))), 0);
|
||||
// Two images the renderer can't decode, each rasterized to a picture of its own.
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}));
|
||||
assert_eq!(r["images"].as_array().unwrap().len(), 3, "{r}");
|
||||
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
|
||||
let dir = std::env::temp_dir().join(format!("vc-css-undecoded-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let r = run(&mut s, "css.export", json!({"path": dir.join("site.css").to_string_lossy(), "scope": "all", "rasterize": true}));
|
||||
assert_eq!(r["images"].as_array().unwrap().len(), 3, "the pictures are still written");
|
||||
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
@@ -199,6 +199,81 @@ fn character_styles_follow_the_setup_sizes() {
|
||||
assert_eq!(def.attrs["small_caps"], json!(80.0));
|
||||
}
|
||||
|
||||
/// A file whose type, symbols, graph label styles, character styles or pattern definition saved in
|
||||
/// pattern editing mode hold superscript, subscript or small caps proportions outside the ranges
|
||||
/// `document.setup` takes opens with each at the nearer end of its range; the other character
|
||||
/// attributes open as saved.
|
||||
#[test]
|
||||
fn a_file_with_script_proportions_out_of_range_opens_with_them_in_range() {
|
||||
let mut s = session();
|
||||
let id = create_text(&mut s, "E=mc2");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap();
|
||||
let water = create_text(&mut s, "H2O");
|
||||
s.execute("text.setRangeStyle", &json!({"id": water.0, "start": 1, "end": 2, "position": "subscript"})).unwrap();
|
||||
let csv = ",2024\nQ1,3\nQ2,5";
|
||||
let graph = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 150, "width": 200, "height": 100, "csv": csv})).unwrap();
|
||||
let graph = NodeId(graph["id"].as_u64().unwrap());
|
||||
let exponent = create_text(&mut s, "x2");
|
||||
s.execute("text.setRangeStyle", &json!({"id": exponent.0, "start": 1, "end": 2, "position": "superscript"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [exponent.0]})).unwrap();
|
||||
s.execute("object.pattern.make", &json!({"name": "Exponent", "width": 20, "height": 20})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
// Saved in pattern editing mode: Cancel puts back the definition from before the edit.
|
||||
s.execute("object.pattern.edit", &json!({"name": "Exponent"})).unwrap();
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].style.small_caps = Some(-5.0);
|
||||
let two = &mut t.runs[1].style;
|
||||
two.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
|
||||
two.small_caps = Some(f64::MAX);
|
||||
}
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(water).map(|n| &mut n.kind) {
|
||||
t.runs[1].style.position = CharPosition::Subscript(ScriptMetrics { size: f64::MAX, position: -1000.0 });
|
||||
t.runs[1].style.tracking = -5000.0;
|
||||
}
|
||||
let mut art = d.node(id).unwrap().clone();
|
||||
art.id = NodeId(9000);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Formula".into(), art: Arc::new(art) });
|
||||
if let Some(g) = d.node_mut(graph).and_then(|n| n.extras_if_any()).and_then(|e| e.graph.as_deref_mut()) {
|
||||
let position = CharPosition::Subscript(ScriptMetrics { size: 0.0, position: 1000.0 });
|
||||
g.label_styles.legend = Some(vectorcraft_doc::CharStyle { position, small_caps: Some(0.0), ..vectorcraft_doc::CharStyle::default() });
|
||||
}
|
||||
let original = d.pattern_edit.as_mut().and_then(|pe| pe.original.as_mut()).and_then(|p| p.art.first_mut());
|
||||
if let Some(NodeKind::Text(t)) = original.map(|a| &mut Arc::make_mut(a).kind) {
|
||||
t.runs[1].style.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
|
||||
}
|
||||
let attrs = json!({"position": {"kind": "superscript", "size": 1000.0, "position": -1000.0}, "small_caps": 0.0, "tracking": -5000.0});
|
||||
d.char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() });
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
|
||||
s.execute("document.open", &json!({"name": "scripts.vectorcraft", "dataBase64": data})).unwrap();
|
||||
let raised = CharPosition::Superscript(ScriptMetrics { size: 1.0, position: 500.0 });
|
||||
let t = text(&s, id);
|
||||
assert_eq!((t.runs[0].style.small_caps, t.runs[1].style.small_caps), (Some(1.0), Some(200.0)));
|
||||
assert_eq!(t.runs[1].style.position, raised);
|
||||
let t = text(&s, water);
|
||||
assert_eq!(t.runs[1].style.position, CharPosition::Subscript(ScriptMetrics { size: 500.0, position: -500.0 }));
|
||||
assert_eq!(t.runs[1].style.tracking, -5000.0, "other attributes open as saved");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
match &doc.symbols.iter().find(|s| s.name == "Formula").unwrap().art.kind {
|
||||
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
|
||||
_ => panic!("the symbol's art is not text"),
|
||||
}
|
||||
let legend = doc.node(graph).unwrap().extras().graph.as_ref().unwrap().label_styles.legend.clone().unwrap();
|
||||
assert_eq!((legend.position, legend.small_caps), (CharPosition::Subscript(ScriptMetrics { size: 1.0, position: 500.0 }), Some(1.0)));
|
||||
let def = doc.char_styles.iter().find(|d| d.name == "Note").unwrap();
|
||||
assert_eq!(def.attrs["position"], json!({"kind": "superscript", "size": 500.0, "position": -500.0}));
|
||||
assert_eq!((&def.attrs["small_caps"], &def.attrs["tracking"]), (&json!(1.0), &json!(-5000.0)));
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
match &s.doc().unwrap().doc.pattern("Exponent").unwrap().art[0].kind {
|
||||
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
|
||||
_ => panic!("the pattern's art is not text"),
|
||||
}
|
||||
// With the file's proportions kept as saved, the move failed with "result would exceed the canvas".
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_survives_save_and_load() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -811,6 +811,44 @@ fn draw2_commands_reject_bad_params() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
|
||||
// `path.freehand` refuse a point whose x or y is 1e9 pt or more in size, also for a path that is
|
||||
// not selected: the document stays as it was, with no undo step. A delta just under that bound,
|
||||
// at t 0.05 or 0.95, leaves every handle and the path's bounds finite.
|
||||
let mut s = session();
|
||||
let id = curve(&mut s);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let finite = |s: &Session| {
|
||||
paths(s).iter().all(|(_, pd)| {
|
||||
let mut points = pd.subpaths.iter().flat_map(|sp| &sp.anchors).flat_map(|a| [a.p, a.h_in, a.h_out]);
|
||||
points.all(|q| q.x.is_finite() && q.y.is_finite()) && pd.control_bounds().is_none_or(|b| b.width().is_finite() && b.height().is_finite())
|
||||
})
|
||||
};
|
||||
let h = 1e308;
|
||||
for (cmd, p) in [
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.95, "dx": h, "dy": 0})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.05, "dx": 0, "dy": -h})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.95, "dx": 0, "dy": h, "perpendicular": true})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 1e9, "dy": 0})),
|
||||
("path.convertAnchor", json!({"id": id.0, "anchor": 0, "to": "smooth", "x": h, "y": -h})),
|
||||
("path.freehand", json!({"points": [[0, 0], [h, 0], [-h, h], [h, h]], "keepSelected": false})),
|
||||
("path.freehand", json!({"points": [[0, 0], [h, 0], [-h, h]], "keepSelected": false, "extend": {"id": id.0, "end": "end"}})),
|
||||
] {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
|
||||
assert!(s.doc().unwrap().doc == before && finite(&s), "{cmd} {p} changed the document");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} left an undo step");
|
||||
}
|
||||
for (t, perpendicular) in [(0.05, false), (0.95, false), (0.05, true), (0.95, true)] {
|
||||
let p = json!({"id": id.0, "segment": 0, "t": t, "dx": 0, "dy": 9.9e8, "perpendicular": perpendicular});
|
||||
s.execute("path.reshapeSegment", &p).unwrap();
|
||||
assert!(finite(&s), "{p}: {:?}", path(&s, id));
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100).
|
||||
fn curve(s: &mut Session) -> NodeId {
|
||||
let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]);
|
||||
|
||||
@@ -43,6 +43,18 @@ fn libraries_are_made_renamed_and_deleted() {
|
||||
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
|
||||
}
|
||||
|
||||
/// A library's file is named after it, and a Windows device name (`CON`, `NUL`, `COM1`…) gets a
|
||||
/// `-` after it, on every platform.
|
||||
#[test]
|
||||
fn a_library_named_after_a_windows_device_gets_a_dash() {
|
||||
let mut s = session();
|
||||
let ids: Vec<String> = ["CON", "nul", "Com1", "LPT³", "Console", "COM10"]
|
||||
.iter()
|
||||
.map(|name| run(&mut s, "library.create", json!({"name": name}))["id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(ids, ["CON-", "nul-", "Com1-", "LPT³-", "Console", "COM10"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
|
||||
let mut s = session();
|
||||
@@ -131,6 +143,25 @@ fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
|
||||
assert_eq!(r["style"], format!("{name} 2"));
|
||||
}
|
||||
|
||||
/// A text style that a library file names with spaces around it comes into the document with the
|
||||
/// name trimmed, as New Character Style and New Paragraph Style trim a name, and applies.
|
||||
#[test]
|
||||
fn a_library_style_named_with_spaces_comes_in_trimmed() {
|
||||
let mut s = session();
|
||||
let style = |attrs: Value| json!([{"name": " Body ", "attrs": attrs}]);
|
||||
let data = json!({"name": "Spaced", "charStyles": style(json!({"size": 31.0})), "paraStyles": style(json!({}))});
|
||||
let lib = run(&mut s, "library.import", json!({"data": data.to_string()}))["id"].as_str().unwrap().to_string();
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
for kind in ["charStyle", "paraStyle"] {
|
||||
assert_eq!(run(&mut s, "library.use", json!({"library": lib, "kind": kind, "item": " Body "}))["style"], "Body", "{kind}");
|
||||
}
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!((d.char_styles[0].name.as_str(), d.para_styles[0].name.as_str()), ("Body", "Body"));
|
||||
let vectorcraft_doc::NodeKind::Text(text) = &d.node(NodeId(t.as_u64().unwrap())).unwrap().kind else { panic!("not text") };
|
||||
assert_eq!((text.runs[0].style.size, text.runs[0].style.style_name.as_deref()), (31.0, Some("Body")));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn libraries_are_kept_in_files_across_sessions() {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Paths stored with linked files and SVG images are followed only on this computer or on the
|
||||
//! document's own share (`fileio::document_path`): opening a document, Link Info, watching linked
|
||||
//! files, and the images an SVG file links to.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::fileio::touched;
|
||||
use crate::tests_links::{Folder, RED, place, png, session};
|
||||
|
||||
/// Paths to other computers (and, on Unix, a local file written as one: `//tmp/…`, which Linux
|
||||
/// reads as `/tmp/…`).
|
||||
fn far(dir: &Folder) -> Vec<String> {
|
||||
let mut out = vec![r"\\192.0.2.1\share\x.png".to_string(), "file://192.0.2.1/s/x.png".to_string()];
|
||||
if cfg!(unix) {
|
||||
out.push(format!("/{}", dir.file("far/far.png")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Did any path read reach another computer (or the local file written as one)?
|
||||
fn far_reads(seen: &[String]) -> Vec<&String> {
|
||||
seen.iter().filter(|p| p.contains("192.0.2.1") || p.starts_with("//") || p.starts_with(r"\\")).collect()
|
||||
}
|
||||
|
||||
/// A document saved at `dir/doc.vectorcraft` with one linked image per path in `paths`, each
|
||||
/// showing `dir/pic.png` until its link is set → the document's path.
|
||||
fn save_linked(dir: &Folder, paths: &[String]) -> String {
|
||||
let pic = dir.file("pic.png");
|
||||
std::fs::write(&pic, png(8, 8, RED)).unwrap();
|
||||
let mut s = session();
|
||||
let ids: Vec<NodeId> = paths.iter().map(|_| place(&mut s, &pic)).collect();
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
for (id, path) in ids.iter().zip(paths) {
|
||||
let Some(NodeKind::Image(im)) = doc.node_mut(*id).map(|n| &mut n.kind) else { panic!("not an image") };
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new(path.clone()));
|
||||
}
|
||||
let path = dir.file("doc.vectorcraft");
|
||||
std::fs::write(&path, vectorcraft_format::save(&doc, false)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn names(rows: &Value) -> Vec<&str> {
|
||||
rows.as_array().unwrap().iter().map(|r| r["path"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_files_on_other_computers_are_never_read() {
|
||||
let dir = Folder::new("docpath-open");
|
||||
std::fs::create_dir_all(dir.0.join("far")).unwrap();
|
||||
std::fs::write(dir.file("far/far.png"), png(8, 8, RED)).unwrap();
|
||||
std::fs::write(dir.file("near.png"), png(8, 8, RED)).unwrap();
|
||||
let far = far(&dir);
|
||||
let mut paths = far.clone();
|
||||
paths.push("near.png".into());
|
||||
let doc = save_linked(&dir, &paths);
|
||||
|
||||
let mut s = Session::new();
|
||||
let (r, seen) = touched(|| s.execute("document.open", &json!({"path": doc})).unwrap());
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
// The far paths are missing, saying why; the file beside the document is found.
|
||||
assert_eq!(names(&r["missingLinks"]), far.iter().map(String::as_str).collect::<Vec<_>>(), "{r}");
|
||||
for row in r["missingLinks"].as_array().unwrap() {
|
||||
assert_eq!(row["refused"], "the path is on another computer", "{row}");
|
||||
}
|
||||
let check = s.execute("links.check", &json!({})).unwrap();
|
||||
let near = check["links"].as_array().unwrap().iter().find(|l| l["name"] == "near.png").unwrap();
|
||||
assert_ne!(near["status"], "missing", "{check}");
|
||||
|
||||
// Link Info and the linked-file watch don't look at them either.
|
||||
let ids: Vec<u64> = r["missingLinks"].as_array().unwrap().iter().map(|row| row["ids"][0].as_u64().unwrap()).collect();
|
||||
let (_, seen) = touched(|| {
|
||||
for id in &ids {
|
||||
let info = s.execute("links.info", &json!({"id": id})).unwrap();
|
||||
assert_eq!(info["refused"], "the path is on another computer", "{info}");
|
||||
}
|
||||
s.execute("links.updateChanged", &json!({})).unwrap();
|
||||
s.start_link_scan();
|
||||
let t0 = std::time::Instant::now();
|
||||
while s.poll_link_scan().is_none() {
|
||||
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
});
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
// The watch kept no stamp of them (`//tmp/…` would have one had it been looked at).
|
||||
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_images_on_other_computers_are_never_read() {
|
||||
let dir = Folder::new("docpath-svg");
|
||||
std::fs::create_dir_all(dir.0.join("far")).unwrap();
|
||||
std::fs::write(dir.file("far/far.png"), png(8, 8, RED)).unwrap();
|
||||
std::fs::write(dir.file("near.png"), png(8, 8, RED)).unwrap();
|
||||
let mut hrefs = vec![r"\\192.0.2.1\share\x.png".to_string(), "file:////192.0.2.1/s/x.png".to_string(), "file://192.0.2.1/s/x.png".to_string()];
|
||||
if cfg!(unix) {
|
||||
hrefs.push(format!("/{}", dir.file("far/far.png")));
|
||||
}
|
||||
hrefs.push("near.png".into());
|
||||
let images: String = hrefs.iter().map(|h| format!("<image width=\"8\" height=\"8\" href=\"{h}\"/>")).collect();
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\">{images}</svg>");
|
||||
let path = dir.file("art.svg");
|
||||
std::fs::write(&path, &svg).unwrap();
|
||||
|
||||
let mut s = Session::new();
|
||||
let (r, seen) = touched(|| s.execute("document.open", &json!({"path": path})).unwrap());
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
// Every far image is a placeholder; only the one beside the SVG is read.
|
||||
let warnings = r["warnings"].as_array().unwrap();
|
||||
assert_eq!(warnings.iter().filter(|w| w.as_str().unwrap().contains("a placeholder keeps its place")).count(), hrefs.len() - 1, "{r}");
|
||||
let near = |p: &&String| p.contains("docpath-svg") && p.ends_with("near.png");
|
||||
assert!(seen.iter().any(|p| near(&p)), "{seen:?}");
|
||||
|
||||
// Pasted SVG text has no folder: only absolute local paths are read.
|
||||
let (_, seen) = touched(|| crate::cmd::fileio::import_svg(&svg, None).unwrap());
|
||||
assert_eq!(far_reads(&seen), Vec::<&String>::new());
|
||||
assert!(!seen.iter().any(|p| near(&p)), "{seen:?}");
|
||||
}
|
||||
@@ -130,6 +130,23 @@ fn duplicate_artboard_copies_its_art() {
|
||||
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
|
||||
}
|
||||
|
||||
/// New Artboard and Artboard Options trim the name they are given, as the Artboards panel's rename
|
||||
/// does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard.
|
||||
#[test]
|
||||
fn new_and_renamed_artboards_trim_their_names() {
|
||||
let mut s = session();
|
||||
let new = |s: &mut Session, name: &str| s.execute("artboard.new", &json!({"name": name})).unwrap();
|
||||
new(&mut s, " Cover \t");
|
||||
new(&mut s, " \t ");
|
||||
new(&mut s, "");
|
||||
let names = |s: &Session| boards(s).into_iter().map(|(_, n)| n).collect::<Vec<_>>();
|
||||
assert_eq!(names(&s), ["Artboard 1", "Cover", "Artboard 3", "Artboard 4"]);
|
||||
s.execute("artboard.setProps", &json!({"index": 0, "name": " Back\t"})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": " "})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 3, "name": ""})).unwrap();
|
||||
assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]);
|
||||
}
|
||||
|
||||
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
|
||||
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
|
||||
/// artboards, the art and the clipboard stay as they were.
|
||||
|
||||
@@ -66,6 +66,39 @@ fn ranged_paragraph_commands_touch_only_their_paragraphs() {
|
||||
assert_eq!(text(&s, id).para_at(2).space_after, 1.0, "past the end: the last paragraph");
|
||||
}
|
||||
|
||||
/// A file whose paragraphs hold an indent or a space before or after past `MAX_COORD`, or a tab
|
||||
/// stop outside 0 to 100,000 pt, opens with the nearer end of each range, in the type and in
|
||||
/// paragraph styles. Values past the Paragraph panel's 1296 pt and within those ranges stay.
|
||||
#[test]
|
||||
fn a_file_with_paragraph_spacing_out_of_range_opens_with_it_in_range() {
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 40, "y": 60, "size": 20, "text": "Line\tone\nLine two"})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
let tab = |position| vectorcraft_doc::TabStop { position, align: vectorcraft_doc::TabAlign::Left, leader: String::new(), align_on: '.' };
|
||||
let mut first = t.para.clone();
|
||||
(first.left_indent, first.first_line_indent, first.space_after) = (f64::MAX, -1e7, 5000.0);
|
||||
first.tabs = vec![tab(-5.0), tab(f64::MAX)];
|
||||
let mut second = t.para.clone();
|
||||
(second.right_indent, second.space_before) = (-f64::MAX, 1e7);
|
||||
t.set_paragraph_styles(vec![first, second]);
|
||||
let attrs = json!({"left_indent": 1e9, "space_after": 2000, "tabs": [serde_json::to_value(tab(-5.0)).unwrap()]});
|
||||
d.para_styles.push(vectorcraft_doc::TextStyleDef { name: "Wide".into(), attrs: attrs.as_object().unwrap().clone() });
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
|
||||
s.execute("document.open", &json!({"name": "spacing.vectorcraft", "dataBase64": data})).unwrap();
|
||||
let max = vectorcraft_doc::MAX_COORD;
|
||||
let t = text(&s, id);
|
||||
let (p0, p1) = (t.para_at(0), t.para_at(1));
|
||||
assert_eq!((p0.left_indent, p0.first_line_indent, p0.space_after), (max, -max, 5000.0));
|
||||
assert_eq!(p0.tabs.iter().map(|t| t.position).collect::<Vec<_>>(), [0.0, 100_000.0]);
|
||||
assert_eq!((p1.right_indent, p1.space_before), (-max, max));
|
||||
let def = &s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Wide").unwrap().attrs;
|
||||
assert_eq!((&def["left_indent"], &def["space_after"], &def["tabs"][0]["position"]), (&json!(max), &json!(2000), &json!(0.0)));
|
||||
// With the spacing kept as saved, the type's bounds were infinite.
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap();
|
||||
assert!(b.width().is_finite() && b.height().is_finite(), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_splits_and_merges_paragraph_styles() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -267,6 +267,41 @@ fn save_a_copy_while_editing() {
|
||||
assert!(doc(&s).pattern("Dots copy").is_some());
|
||||
}
|
||||
|
||||
/// Make, Save a Copy and Pattern Options trim the name they are given, as the Pattern Options
|
||||
/// panel does; the trimmed name is the one compared with the names there are, and a blank name
|
||||
/// gives the name the command gives when there is none.
|
||||
#[test]
|
||||
fn made_copied_and_renamed_patterns_trim_their_names() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let make = |s: &mut Session, name: &str| {
|
||||
let r = rect(s, 0.0, 0.0, 10.0, 10.0);
|
||||
s.execute("object.pattern.make", &json!({"ids": [r.0], "name": name, "edit": false})).unwrap()["name"].clone()
|
||||
};
|
||||
assert_eq!(make(&mut s, " Grid \t"), "Grid");
|
||||
assert_eq!(make(&mut s, " Dots"), "Dots 2");
|
||||
assert_eq!(make(&mut s, ""), "New Pattern 1");
|
||||
assert_eq!(make(&mut s, " \t "), "New Pattern 2");
|
||||
s.execute("object.pattern.edit", &json!({"name": "Grid"})).unwrap();
|
||||
assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": " Lines\t"})).unwrap()["name"], "Lines");
|
||||
assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": " "})).unwrap()["name"], "Grid copy");
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("pattern.options", &json!({"name": "Dots 2", "newName": " Spots "})).unwrap()["name"], "Spots");
|
||||
assert_eq!(s.execute("pattern.options", &json!({"name": "Spots", "newName": " "})).unwrap()["name"], "Spots");
|
||||
let e = s.execute("pattern.options", &json!({"name": "Spots", "newName": " Lines"})).unwrap_err().to_string();
|
||||
assert!(e.contains("a pattern named `Lines` exists"), "{e}");
|
||||
// Save a Copy names the copy of a pattern whose saved name has white space around it, as a
|
||||
// saved file can hold, from the trimmed name (" Old " gives "Old copy").
|
||||
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
d.patterns.iter_mut().filter(|p| p.name == "Grid copy").for_each(|p| p.name = " Old ".into());
|
||||
s.execute("object.pattern.edit", &json!({"name": " Old "})).unwrap();
|
||||
assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Old copy");
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
let names: Vec<&str> = doc(&s).patterns.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["Dots", "Grid", "Spots", "New Pattern 1", "New Pattern 2", "Lines", " Old ", "Old copy"]);
|
||||
assert!(doc(&s).swatch("Spots").is_some() && doc(&s).swatch(" Spots ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_make_pattern() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -240,6 +240,20 @@ fn linux_display_server_defaults_to_automatic_and_validates() {
|
||||
assert_eq!(s.prefs.linux_display_server, "automatic");
|
||||
}
|
||||
|
||||
/// Linux Display Server's pen choice (#2028): set by value or by label, saved, and off by default.
|
||||
#[test]
|
||||
fn linux_display_server_has_a_choice_for_an_attached_pen() {
|
||||
let mut s = Session::new();
|
||||
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "x11WithPen"})).unwrap();
|
||||
assert_eq!(s.prefs.linux_display_server, "x11WithPen");
|
||||
assert_eq!(s.prefs.to_json()["linuxDisplayServer"], "x11WithPen");
|
||||
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "automatic"})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "X11 (XWayland) When a Pen Is Attached"})).unwrap();
|
||||
assert_eq!(s.prefs.linux_display_server, "x11WithPen");
|
||||
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
|
||||
assert_eq!(s.prefs.linux_display_server, "automatic");
|
||||
}
|
||||
|
||||
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
|
||||
/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them
|
||||
/// (#394).
|
||||
@@ -1196,6 +1210,55 @@ fn missing_glyph_protection_keeps_glyphs_a_new_font_lacks() {
|
||||
assert_eq!(fonts(&s), [to, to, to]);
|
||||
}
|
||||
|
||||
/// A character that keeps its font through Missing Glyph Protection keeps that font's Variable
|
||||
/// Font settings too; the characters that take the new font lose them.
|
||||
#[test]
|
||||
fn missing_glyph_protection_keeps_the_variable_font_settings_of_the_font_kept() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FROM, two_axis_font};
|
||||
let (to, c) = ("JetBrains Mono", '\u{180}');
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(two_axis_font().unwrap());
|
||||
if !db.face(FROM, "Regular").is_some_and(|f| f.covers(c)) || db.face(to, "Regular").is_some_and(|f| f.covers(c)) {
|
||||
return; // An installed font of that name covers the character.
|
||||
}
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": FROM})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
let fonts = |s: &Session| [0, 1, 3].map(|b| style_at(s, id, b)).map(|st| (st.font_family, st.font_axes.get("wght").copied()));
|
||||
let (kept, moved) = ((FROM.to_string(), Some(650.0)), (to.to_string(), None));
|
||||
for change in [json!({"font": to}), json!({"id": id.0, "start": 0, "end": 4, "font": to})] {
|
||||
let cmd = if change.get("id").is_some() { "text.setRangeStyle" } else { "text.setStyle" };
|
||||
s.execute(cmd, &change).unwrap();
|
||||
assert_eq!(fonts(&s), [moved.clone(), kept.clone(), moved.clone()], "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A character that keeps its font through Missing Glyph Protection keeps the version of it the
|
||||
/// type names.
|
||||
#[test]
|
||||
fn missing_glyph_protection_keeps_the_version_of_the_font_kept() {
|
||||
use vectorcraft_text::test_fonts::{TWIN_FAMILY as FROM, TWIN_VERSIONS, twin_font};
|
||||
let (to, c) = ("JetBrains Mono", '\u{180}');
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
db.add_font(twin_font(false).unwrap());
|
||||
let older = db.face_version(FROM, "Regular", Some(TWIN_VERSIONS[0]));
|
||||
if !older.is_some_and(|f| f.version == TWIN_VERSIONS[0] && f.covers(c)) || db.face(to, "Regular").is_some_and(|f| f.covers(c)) {
|
||||
return; // An installed font of that name covers the character.
|
||||
}
|
||||
let mut s = new_doc();
|
||||
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": FROM})).unwrap()["id"].as_u64().unwrap());
|
||||
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].style.font_version = Some(TWIN_VERSIONS[0].into());
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"font": to})).unwrap();
|
||||
let versions = [0, 1, 3].map(|b| (style_at(&s, id, b).font_family, style_at(&s, id, b).font_version));
|
||||
let (kept, moved) = ((FROM.to_string(), Some(TWIN_VERSIONS[0].to_string())), (to.to_string(), None));
|
||||
assert_eq!(versions, [moved.clone(), kept, moved]);
|
||||
}
|
||||
|
||||
// ---------- #394, fourth batch ----------
|
||||
|
||||
/// Type › Type Object Selection by Path Only (#394): on, a click among point type's glyphs no
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Effect → Document Raster Effects Settings (`document.rasterEffectsSettings`): the settings, the
|
||||
//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults.
|
||||
//! raster effect images they make (PDF export, Expand Appearance), the Rasterize defaults, and
|
||||
//! Effect › Rasterize's image.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
|
||||
@@ -199,6 +200,156 @@ fn rasterize_takes_the_document_settings_as_defaults() {
|
||||
assert!(s.execute("object.rasterize", &json!({"colorModel": "lab"})).is_err());
|
||||
}
|
||||
|
||||
/// An image Object › Rasterize or Expand Appearance made, `n` or the image in its clip group:
|
||||
/// (width, height, where its pixels lie, its PNG, the clipping path's geometry).
|
||||
fn rasterized(d: &Document, n: &vectorcraft_doc::Node) -> (u32, u32, vectorcraft_geom::Affine, Vec<u8>, Option<vectorcraft_geom::PathData>) {
|
||||
let (image, clip) = match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => (&*children[1], children[0].path_data().cloned()),
|
||||
_ => (n, None),
|
||||
};
|
||||
let NodeKind::Image(im) = &image.kind else { panic!("an image: {:?}", image.kind_label()) };
|
||||
(im.width, im.height, im.xf, d.images[&im.key].bytes.to_vec(), clip)
|
||||
}
|
||||
|
||||
/// A rectangle of 40 × 30 pt with its default stroke and a red fill, selected.
|
||||
fn red_rectangle(s: &mut Session) -> NodeId {
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 50.5, "y": 50.25, "width": 40, "height": 30})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rasterize_effect_keeps_the_path_and_expands_to_the_image_object_rasterize_makes() {
|
||||
let cases = [
|
||||
(
|
||||
json!({"useDocumentResolution": false, "resolution": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}),
|
||||
json!({"ppi": 150, "background": "white", "colorModel": "grayscale", "antiAlias": "none", "addAround": 3}),
|
||||
),
|
||||
// At the document's resolution (300 ppi here), clipped to the art's outline.
|
||||
(
|
||||
json!({"clippingMask": true, "antiAlias": "type"}),
|
||||
json!({"ppi": 300, "clippingMask": true, "antiAlias": "type", "background": "transparent"}),
|
||||
),
|
||||
];
|
||||
for (effect, object) in cases {
|
||||
let mut s = session();
|
||||
s.execute(C, &json!({"resolution": "high"})).unwrap();
|
||||
let r = red_rectangle(&mut s);
|
||||
let before = s.doc().unwrap().doc.node(r).unwrap().path_data().cloned();
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": effect, "ids": [r.0]})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let n = d.node(r).unwrap();
|
||||
assert_eq!((n.path_data().cloned(), n.appearance.effects[0].id.as_str()), (before, "rasterize"), "the path stays as it was");
|
||||
// Saved and read back.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
|
||||
assert_eq!(back.node(r).unwrap().appearance.effects, n.appearance.effects);
|
||||
// Object › Rasterize of the same rectangle.
|
||||
let mut o = session();
|
||||
o.execute(C, &json!({"resolution": "high"})).unwrap();
|
||||
red_rectangle(&mut o);
|
||||
let made = id(&o.execute("object.rasterize", &object).unwrap());
|
||||
let od = &o.doc().unwrap().doc;
|
||||
let want = rasterized(od, od.node(made).unwrap());
|
||||
// PDF export draws that image in its place.
|
||||
let flat = crate::flatten_raster_effects(&d).unwrap();
|
||||
assert_eq!(rasterized(&flat, &flat.layers[0].children().unwrap()[0]), want, "{effect}");
|
||||
// So does Expand Appearance.
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(rasterized(d, d.node(r).unwrap()), want, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rasterize_effect_follows_edits_to_the_path() {
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap();
|
||||
let width = |s: &Session| {
|
||||
let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap();
|
||||
rasterized(&flat, &flat.layers[0].children().unwrap()[0]).0
|
||||
};
|
||||
let before = width(&s);
|
||||
// Direct Selection moves the right-hand anchors 20 pt: the image widens with the path.
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 1], [0, 2]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 20, "dy": 0})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(r).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Path { .. }) && n.appearance.effects.len() == 1);
|
||||
assert_eq!(width(&s), before + 20, "72 ppi: a pixel a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_measures_the_art_its_geometry_effects_draw() {
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [r.0]})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"background": "white"}, "ids": [r.0]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (width, _, xf, ..) = rasterized(d, d.node(r).unwrap());
|
||||
// The rectangle as the Transform effect draws it: 100 pt to the right, 41 pt wide with its stroke.
|
||||
assert_eq!((xf.translation().x, width), (150.0, 41));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_mask_follows_the_art_its_geometry_effects_draw() {
|
||||
let clip = |s: &Session, n: NodeId| match &s.doc().unwrap().doc.node(n).unwrap().kind {
|
||||
NodeKind::Group { children, clip: true } => children[0].geometric_bounds(),
|
||||
_ => None,
|
||||
};
|
||||
let moved = |s: &mut Session, n: NodeId| {
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [n.0]})).unwrap();
|
||||
};
|
||||
// Expand Appearance clips the image to the rectangle drawn 100 pt to the right, from 150.5 to
|
||||
// 190.5 pt.
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
moved(&mut s, r);
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": {"clippingMask": true}, "ids": [r.0]})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
|
||||
let b = clip(&s, r).expect("a clip group");
|
||||
assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}");
|
||||
// Object › Rasterize clips its image there too.
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
moved(&mut s, r);
|
||||
let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap());
|
||||
let b = clip(&s, made).expect("a clip group");
|
||||
assert!((b.x0 - 150.5).abs() < 1e-6 && (b.x1 - 190.5).abs() < 1e-6, "{b:?}");
|
||||
// Type's image is clipped to its glyphs, 100 pt right of where they are without the effect.
|
||||
let glyphs = |effect: bool| {
|
||||
let mut s = session();
|
||||
let t = id(&s.execute("text.create", &json!({"x": 50, "y": 100, "text": "H", "size": 40})).unwrap());
|
||||
if effect {
|
||||
moved(&mut s, t);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
|
||||
let made = id(&s.execute("object.rasterize", &json!({"clippingMask": true})).unwrap());
|
||||
clip(&s, made).expect("a clip group")
|
||||
};
|
||||
let (plain, shifted) = (glyphs(false), glyphs(true));
|
||||
assert!((shifted.x0 - plain.x0 - 100.0).abs() < 1e-6 && (shifted.x1 - plain.x1 - 100.0).abs() < 1e-6, "{plain:?} {shifted:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_rasterize_takes_in_everything_an_effect_stack_paints() {
|
||||
let mut s = session();
|
||||
let r = red_rectangle(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 4}, "ids": [r.0]})).unwrap();
|
||||
let live = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 10});
|
||||
s.execute("effect.apply", &json!({"effect": "rasterize", "params": live, "ids": [r.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let made = id(&s.execute("object.rasterize", &json!({"ppi": 72, "background": "transparent"})).unwrap());
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (width, height, xf, png, _) = rasterized(d, d.node(made).unwrap());
|
||||
let img = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
// 41 pt of rectangle and stroke, and 6 pt of blur and 10 pt of white on each side: white from
|
||||
// the first column to the last.
|
||||
assert_eq!((width, xf.translation().x), (73, 34.0));
|
||||
assert_eq!((img.get_pixel(0, height / 2).0, img.get_pixel(width - 1, height / 2).0), ([255; 4], [255; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_settings_round_trip_and_old_files_load() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
|
||||
@@ -387,6 +387,57 @@ fn shaper_tool_trims_both_diagonal_ends_and_only_construction_can_pick_them() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![line]);
|
||||
}
|
||||
|
||||
/// The Selection tool's marquee, Group Selection's E marquee and Direct Selection's marquee and
|
||||
/// clicks reach a Shaper Group only by the art it shows: none of them selects the original shape of a
|
||||
/// face the group erased, or its anchor points. In Construction mode the Selection marquee selects
|
||||
/// them, and Direct Selection's marquee and clicks select their anchor points.
|
||||
#[test]
|
||||
fn shaper_marquees_leave_the_original_shape_of_an_erased_face() {
|
||||
let mut s = session();
|
||||
rectangle(&mut s, 0.0, "#ff0000");
|
||||
let b = rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[110.0, 20.0], [140.0, 35.0], [110.0, 50.0], [140.0, 65.0], [200.0, 70.0]]);
|
||||
assert_eq!(fill(&s, g, 125.0, 50.0), None, "the face is erased");
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let drag = |s: &mut Session, tool: &str, from: [f64; 2], to: [f64; 2]| {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (kind, [x, y]) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
|
||||
}
|
||||
s.doc().unwrap().selection.clone()
|
||||
};
|
||||
// Each marquee crosses the second rectangle's right side, where nothing is shown.
|
||||
assert!(drag(&mut s, "selection", [140.0, 40.0], [160.0, 60.0]).objects.is_empty());
|
||||
let sel = drag(&mut s, "directSelection", [140.0, -10.0], [160.0, 10.0]);
|
||||
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
|
||||
// Group Selection's E marquee around the whole of that rectangle leaves it out.
|
||||
s.select_tool("groupSelection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 155.0, -10.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 45.0, 110.0), v).unwrap();
|
||||
s.tool_key(vectorcraft_tools::ToolKey::Letter('e'), Mods::default(), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 45.0, 110.0), v).unwrap();
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert!(!sel.objects.contains(&b) && !sel.anchors.contains_key(&b), "{sel:?}");
|
||||
// A Direct Selection click on that rectangle's corner selects nothing either.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let sel = drag(&mut s, "directSelection", [150.0, 0.0], [150.0, 0.0]);
|
||||
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "{sel:?}");
|
||||
// Over the shown face's left side the marquees take the group and the face's anchors.
|
||||
assert_eq!(drag(&mut s, "selection", [-10.0, 40.0], [10.0, 60.0]).objects, vec![g]);
|
||||
let sources = node(&s, g).children().unwrap()[0].id;
|
||||
let sel = drag(&mut s, "directSelection", [-10.0, -10.0], [10.0, 10.0]);
|
||||
assert!(!sel.anchors.is_empty());
|
||||
assert!(sel.anchors.keys().all(|id| s.doc().unwrap().doc.parent_of(*id) == Some(g)), "{sel:?}");
|
||||
// Construction mode: the original shapes are what the marquees select. These marquees start
|
||||
// outside them and cross the second rectangle's top right corner.
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(sources));
|
||||
assert_eq!(drag(&mut s, "selection", [160.0, -10.0], [140.0, 10.0]).objects, vec![b]);
|
||||
assert!(drag(&mut s, "directSelection", [160.0, -10.0], [140.0, 10.0]).anchors.contains_key(&b));
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(drag(&mut s, "directSelection", [150.0, 0.0], [150.0, 0.0]).anchors.contains_key(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_three_circles_erase_a_visible_fill_and_keep_the_complete_shared_curve() {
|
||||
for exits in [false, true] {
|
||||
|
||||
@@ -40,6 +40,82 @@ fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow()
|
||||
assert!(!dir.join(format!("{}.vcpdfpresets", file_stem("Old"))).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_that_cannot_be_written_keeps_the_preset_under_its_old_name() {
|
||||
let dir = fresh_dir("rename-fails").join("PDF Presets");
|
||||
let mut a = Session::new();
|
||||
a.attach_pdf_preset_store(dir.clone());
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
|
||||
// The test puts a folder where the new name's file goes, so the write fails as it would on a full disk.
|
||||
std::fs::create_dir(dir.join(format!("{}.vcpdfpresets", file_stem("Screen")))).unwrap();
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Screen"})).unwrap();
|
||||
|
||||
let mut b = Session::new();
|
||||
b.attach_pdf_preset_store(dir.clone());
|
||||
assert_eq!(saved_names(&mut b), ["Web"]);
|
||||
assert_eq!(b.prefs.pdf_presets[0].description, "for screens");
|
||||
assert!(dir.join(format!("{}.vcpdfpresets", file_stem("Web"))).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_that_only_changes_case_keeps_one_file() {
|
||||
let dir = fresh_dir("rename-case").join("PDF Presets");
|
||||
let mut a = Session::new();
|
||||
a.attach_pdf_preset_store(dir.clone());
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "WEB", "description": "renamed"})).unwrap();
|
||||
|
||||
let mut b = Session::new();
|
||||
b.attach_pdf_preset_store(dir.clone());
|
||||
assert_eq!(saved_names(&mut b), ["WEB"]);
|
||||
assert_eq!(b.prefs.pdf_presets[0].description, "renamed");
|
||||
assert_eq!(file_names(&dir), [format!("{}.vcpdfpresets", file_stem("WEB"))]);
|
||||
}
|
||||
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect()
|
||||
}
|
||||
|
||||
/// Windows does not allow replacing a read-only file or writing into it, so the write fails.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_rename_that_only_changes_case_and_cannot_be_written_keeps_the_old_file() {
|
||||
let dir = fresh_dir("rename-case-fails").join("PDF Presets");
|
||||
let mut a = Session::new();
|
||||
a.attach_pdf_preset_store(dir.clone());
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
|
||||
let old = dir.join(format!("{}.vcpdfpresets", file_stem("Web")));
|
||||
let writable = std::fs::metadata(&old).unwrap().permissions();
|
||||
let mut read_only = writable.clone();
|
||||
read_only.set_readonly(true);
|
||||
std::fs::set_permissions(&old, read_only).unwrap();
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "WEB"})).unwrap();
|
||||
std::fs::set_permissions(&old, writable).unwrap();
|
||||
|
||||
let mut b = Session::new();
|
||||
b.attach_pdf_preset_store(dir.clone());
|
||||
assert_eq!(saved_names(&mut b), ["Web"]);
|
||||
assert_eq!(b.prefs.pdf_presets[0].description, "for screens");
|
||||
assert_eq!(file_names(&dir), [format!("{}.vcpdfpresets", file_stem("Web"))]);
|
||||
}
|
||||
|
||||
/// Where file names keep case apart, a folder named like the new file makes the rename fail.
|
||||
#[cfg(not(any(windows, target_vendor = "apple")))]
|
||||
#[test]
|
||||
fn a_rename_that_only_changes_case_and_cannot_be_moved_keeps_the_old_preset() {
|
||||
let dir = fresh_dir("rename-case-blocked").join("PDF Presets");
|
||||
let mut a = Session::new();
|
||||
a.attach_pdf_preset_store(dir.clone());
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "description": "for screens"})).unwrap();
|
||||
std::fs::create_dir(dir.join(format!("{}.vcpdfpresets", file_stem("WEB")))).unwrap();
|
||||
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "WEB", "description": "renamed"})).unwrap();
|
||||
|
||||
let mut b = Session::new();
|
||||
b.attach_pdf_preset_store(dir.clone());
|
||||
assert_eq!(saved_names(&mut b), ["Web"]);
|
||||
assert_eq!(b.prefs.pdf_presets[0].description, "for screens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() {
|
||||
let dir = fresh_dir("seed").join("PDF Presets");
|
||||
|
||||
@@ -1020,3 +1020,34 @@ fn overflow_marker_shows_with_the_selection_tool() {
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert_eq!(red(&mut s), 0, "nothing selected");
|
||||
}
|
||||
|
||||
/// #2572: with the Selection tool, a click on the out port and a click on empty canvas thread the
|
||||
/// overflow into a new frame there, in one undo step; Escape, or another tool, drops the loaded text.
|
||||
#[test]
|
||||
fn clicking_the_out_port_then_the_canvas_threads_the_text_into_a_new_frame() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let long = "Words that fill more than the frame holds. ".repeat(20);
|
||||
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let port = Point::new(150.0, 90.0 - vectorcraft_tools::text::PORT_RISE_PX);
|
||||
let loaded = |s: &mut Session| s.cursor(Point::new(400.0, 400.0), Mods::default(), v) == vectorcraft_tools::Cursor::LoadedText;
|
||||
click(&mut s, port.x, port.y);
|
||||
assert!(loaded(&mut s));
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert!(!loaded(&mut s), "Escape drops the text");
|
||||
click(&mut s, port.x, port.y);
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(!loaded(&mut s), "another tool drops the text");
|
||||
let steps = undo_labels(&s).len();
|
||||
click(&mut s, port.x, port.y);
|
||||
click(&mut s, 400.0, 400.0);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let [thread] = &doc.text_threads[..] else { panic!("one thread: {:?}", doc.text_threads) };
|
||||
assert_eq!(thread[0], id);
|
||||
assert_eq!(format!("{}{}", obj(&s, id).plain_text(), obj(&s, thread[1]).plain_text()), long);
|
||||
assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! File → Place of a text file (Text Import Options) or an RTF file: the text set as area type.
|
||||
//! File → Place of a text file (Text Import Options), an RTF file or a Word document: the text set
|
||||
//! as area type.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextKind};
|
||||
@@ -162,12 +163,278 @@ fn malformed_rtf_fails_cleanly() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
|
||||
}
|
||||
|
||||
/// A ZIP archive of `files`, each deflated, its headers declaring each file's size, or `declared`
|
||||
/// for every file.
|
||||
fn deflated_zip(files: &[(&str, &[u8])], declared: Option<u32>) -> Vec<u8> {
|
||||
let (mut out, mut central) = (vec![], vec![]);
|
||||
for (name, data) in files {
|
||||
let mut e = flate2::write::DeflateEncoder::new(vec![], flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut e, data).unwrap();
|
||||
let packed = e.finish().unwrap();
|
||||
// Flags, method (8: deflated), time, date, CRC, sizes and the name's length.
|
||||
let mut shared = vec![];
|
||||
for v in [0u16, 8, 0, 0] {
|
||||
shared.extend(v.to_le_bytes());
|
||||
}
|
||||
for v in [crc32fast::hash(data), packed.len() as u32, declared.unwrap_or(data.len() as u32)] {
|
||||
shared.extend(v.to_le_bytes());
|
||||
}
|
||||
shared.extend((name.len() as u16).to_le_bytes());
|
||||
let offset = out.len() as u32;
|
||||
out.extend(0x0403_4b50u32.to_le_bytes());
|
||||
out.extend(20u16.to_le_bytes());
|
||||
out.extend(&shared);
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out.extend(name.as_bytes());
|
||||
out.extend(&packed);
|
||||
central.extend(0x0201_4b50u32.to_le_bytes());
|
||||
central.extend([20u16, 20].map(u16::to_le_bytes).concat());
|
||||
central.extend(&shared);
|
||||
central.extend([0u8; 12]);
|
||||
central.extend(offset.to_le_bytes());
|
||||
central.extend(name.as_bytes());
|
||||
}
|
||||
let start = out.len() as u32;
|
||||
out.extend(¢ral);
|
||||
out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
out.extend([0u8; 4]);
|
||||
out.extend([files.len() as u16; 2].map(u16::to_le_bytes).concat());
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(start.to_le_bytes());
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
|
||||
/// A Word document's package: its main part `body` (the inside of `w:body`), with a styles part
|
||||
/// and a theme part named through the main part's relationships.
|
||||
fn docx(body: &str, styles: &str) -> Vec<u8> {
|
||||
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let rels = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
let types = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/></Types>"#;
|
||||
let root =
|
||||
format!(r#"<Relationships xmlns="{rels}"><Relationship Id="rId1" Type="{rel}/officeDocument" Target="word/document.xml"/></Relationships>"#);
|
||||
let doc_rels = format!(
|
||||
r#"<Relationships xmlns="{rels}"><Relationship Id="rId1" Type="{rel}/styles" Target="styles.xml"/><Relationship Id="rId2" Type="{rel}/theme" Target="theme/theme1.xml"/><Relationship Id="rId3" Type="{rel}/hyperlink" Target="https://example.com/" TargetMode="External"/></Relationships>"#
|
||||
);
|
||||
let document = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W_NS}"><w:body>{body}<w:sectPr/></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!(r#"<w:styles xmlns:w="{W_NS}">{styles}</w:styles>"#);
|
||||
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Test"><a:themeElements><a:fontScheme name="Test"><a:majorFont><a:latin typeface="Arial"/></a:majorFont><a:minorFont><a:latin typeface="Georgia"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
|
||||
let files: [(&str, &[u8]); 6] = [
|
||||
("[Content_Types].xml", types.as_bytes()),
|
||||
("_rels/.rels", root.as_bytes()),
|
||||
("word/document.xml", document.as_bytes()),
|
||||
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
|
||||
("word/styles.xml", styles.as_bytes()),
|
||||
("word/theme/theme1.xml", theme.as_bytes()),
|
||||
];
|
||||
deflated_zip(&files, None)
|
||||
}
|
||||
|
||||
/// Document defaults in the theme's minor font at 11 pt with 8 pt after each paragraph, a default
|
||||
/// Normal style, a centered, bold Heading 1 in the theme's major font at 16 pt and in a color, a
|
||||
/// bold character style and a default character style in all caps.
|
||||
const STYLES: &str = concat!(
|
||||
r#"<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/><w:sz w:val="22"/></w:rPr></w:rPrDefault>"#,
|
||||
r#"<w:pPrDefault><w:pPr><w:spacing w:after="160"/></w:pPr></w:pPrDefault></w:docDefaults>"#,
|
||||
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>"#,
|
||||
r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Normal"/><w:pPr><w:jc w:val="center"/><w:spacing w:before="240" w:after="0"/></w:pPr>"#,
|
||||
r#"<w:rPr><w:rFonts w:asciiTheme="majorHAnsi"/><w:b/><w:sz w:val="32"/><w:color w:val="1F3864"/></w:rPr></w:style>"#,
|
||||
r#"<w:style w:type="character" w:styleId="Strong"><w:rPr><w:b/></w:rPr></w:style>"#,
|
||||
r#"<w:style w:type="character" w:default="1" w:styleId="Plain"><w:rPr><w:caps/></w:rPr></w:style>"#,
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn docx_places_as_area_type_with_its_styles_and_formatting() {
|
||||
let body = concat!(
|
||||
r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t xml:space="preserve">Report </w:t></w:r>"#,
|
||||
r#"<w:r><w:rPr><w:rStyle w:val="Strong"/></w:rPr><w:t>notes</w:t></w:r></w:p>"#,
|
||||
r#"<w:p><w:pPr><w:ind w:left="720" w:hanging="360"/><w:jc w:val="both"/></w:pPr><w:r><w:t xml:space="preserve">Plain and </w:t></w:r>"#,
|
||||
r#"<w:r><w:rPr><w:rFonts w:ascii="Courier New"/><w:i/><w:u w:val="single"/></w:rPr><w:t>styled</w:t></w:r>"#,
|
||||
r#"<w:r><w:rPr><w:vertAlign w:val="superscript"/></w:rPr><w:t>2</w:t></w:r><w:r><w:rPr><w:vanish/></w:rPr><w:t>hidden</w:t></w:r>"#,
|
||||
r#"<w:del w:id="1" w:author="A"><w:r><w:delText>gone</w:delText></w:r></w:del><w:ins w:id="2" w:author="A"><w:r><w:t xml:space="preserve"> page </w:t></w:r></w:ins>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>"#,
|
||||
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
|
||||
);
|
||||
let mut s = session();
|
||||
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
|
||||
let file = json!({"name": "report.docx", "dataBase64": vectorcraft_format::base64_encode(&docx(body, STYLES))});
|
||||
let r = s.execute("file.place", &file).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["name"].as_str()), (Some("docx"), Some("report.docx")));
|
||||
let (text, frame) = placed(&s, &r);
|
||||
assert_eq!(text, "Report notes\nPlain and styled2 page 7", "hidden and deleted text and the field's instructions are left out");
|
||||
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
|
||||
let runs: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str(), r.style.font_style.as_str(), r.style.size)).collect();
|
||||
assert_eq!(
|
||||
runs,
|
||||
[
|
||||
("Report ", "Arial", "Bold", 16.0),
|
||||
// Bold in the character style reverses the paragraph style's bold.
|
||||
("notes", "Arial", "Regular", 16.0),
|
||||
("\n", "Arial", "Bold", 16.0),
|
||||
("Plain and ", "Georgia", "Regular", 11.0),
|
||||
("styled", "Courier New", "Italic", 11.0),
|
||||
("2", "Georgia", "Regular", 11.0),
|
||||
(" page 7", "Georgia", "Regular", 11.0),
|
||||
]
|
||||
);
|
||||
let navy = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0x1F, 0x38, 0x64));
|
||||
assert_eq!((t.runs[0].style.fill == navy, t.runs[1].style.fill == navy, t.runs[3].style.fill == navy), (true, true, false));
|
||||
assert_eq!((t.runs[3].style.underline, t.runs[4].style.underline), (false, true));
|
||||
let caps: Vec<_> = t.runs.iter().map(|r| r.style.all_caps).collect();
|
||||
assert_eq!(caps, [true, false, false, true, true, true, true], "runs without a character style take the default one");
|
||||
let superscript = vectorcraft_doc::ScriptMetrics { size: 50.0, position: 40.0 };
|
||||
assert_eq!(t.runs[5].style.position, vectorcraft_doc::CharPosition::Superscript(superscript), "in Document Setup's proportions");
|
||||
let paras = t.paragraph_styles();
|
||||
let summary: Vec<_> = paras.iter().map(|p| (p.justify, p.left_indent, p.first_line_indent, p.space_before, p.space_after)).collect();
|
||||
assert_eq!(summary, [(vectorcraft_doc::Justify::Center, 0.0, 0.0, 12.0, 0.0), (vectorcraft_doc::Justify::JustifyLeft, 36.0, -18.0, 0.0, 8.0)]);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (2, 1), "Document Setup and Place, each one undo step");
|
||||
// Info and the place cursor report it as a Word document.
|
||||
let info = s.execute("file.place.info", &file).unwrap();
|
||||
assert_eq!((info["format"].as_str(), info["width"].as_f64()), (Some("docx"), Some(468.0)));
|
||||
let q = s.execute("file.place.queue", &json!({"files": [file]})).unwrap();
|
||||
assert_eq!((q["count"].as_u64(), q["files"][0]["format"].as_str()), (Some(1), Some("docx")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docx_double_strikethrough_is_not_a_toggle_property() {
|
||||
// A paragraph style and a character style that both set double strikethrough, and a run that
|
||||
// turns it off directly.
|
||||
let styles = concat!(
|
||||
r#"<w:style w:type="paragraph" w:styleId="Struck"><w:rPr><w:dstrike/></w:rPr></w:style>"#,
|
||||
r#"<w:style w:type="character" w:styleId="AlsoStruck"><w:rPr><w:dstrike/></w:rPr></w:style>"#,
|
||||
);
|
||||
let body = concat!(
|
||||
r#"<w:p><w:pPr><w:pStyle w:val="Struck"/></w:pPr><w:r><w:t xml:space="preserve">one </w:t></w:r>"#,
|
||||
r#"<w:r><w:rPr><w:rStyle w:val="AlsoStruck"/></w:rPr><w:t xml:space="preserve">two </w:t></w:r>"#,
|
||||
r#"<w:r><w:rPr><w:rStyle w:val="AlsoStruck"/><w:dstrike w:val="0"/></w:rPr><w:t>three</w:t></w:r></w:p>"#,
|
||||
);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({"name": "struck.docx", "dataBase64": vectorcraft_format::base64_encode(&docx(body, styles))})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
|
||||
let struck: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.strikethrough)).collect();
|
||||
assert_eq!(struck, [("one two ", true), ("three", false)], "the character style keeps the paragraph style's double strikethrough");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docx_tables_breaks_and_fields_place_as_paragraphs_and_tabs() {
|
||||
// The Strict namespace, a package without relationships or styles, stored without compression
|
||||
// and named without `.docx`.
|
||||
let document = concat!(
|
||||
r#"<w:document xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main"><w:body>"#,
|
||||
r#"<w:p><w:r><w:t>Line one</w:t><w:br/><w:t>line two</w:t><w:br/><w:br w:type="page"/></w:r></w:p>"#,
|
||||
r#"<w:tbl><w:tblPr/><w:tr><w:tc><w:p><w:r><w:t>a</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>b</w:t></w:r></w:p></w:tc></w:tr>"#,
|
||||
r#"<w:tr><w:tc><w:p><w:r><w:t>c</w:t></w:r></w:p></w:tc><w:tc><w:p/></w:tc>"#,
|
||||
r#"<w:sdt><w:sdtContent><w:tc><w:p><w:hyperlink><w:r><w:t>d</w:t></w:r></w:hyperlink></w:p></w:tc></w:sdtContent></w:sdt></w:tr>"#,
|
||||
r#"<w:tr><w:tc><w:p><w:r><w:t>e</w:t></w:r></w:p></w:tc><w:tc><w:p/><w:p><w:r><w:t>f</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#,
|
||||
r#"<w:p><w:fldSimple w:instr=" DATE "><w:r><w:t>today</w:t></w:r></w:fldSimple><w:r><w:tab/><w:t>end</w:t></w:r>"#,
|
||||
r#"<w:r><w:drawing><w:t>not text</w:t></w:drawing></w:r></w:p>"#,
|
||||
r#"<w:p><w:pPr><w:bidi/><w:jc w:val="both"/></w:pPr><w:r><w:t><!-- a comment -->justi<?pi x?>fied</w:t></w:r></w:p>"#,
|
||||
r#"<w:p><w:pPr><w:bidi/><w:jc w:val="end"/><w:ind w:start="400"/></w:pPr><w:r><w:t>rtl</w:t></w:r></w:p><w:p/><w:p/>"#,
|
||||
"</w:body></w:document>"
|
||||
);
|
||||
let mut zip = cmd::fileio::zip::store(&[("word/document.xml", document)]).unwrap();
|
||||
// An archive comment holding the end record's signature.
|
||||
let comment = b"note PK\x05\x06 and 22 more bytes after it, at least";
|
||||
let length = zip.len() - 2;
|
||||
zip[length..].copy_from_slice(&(comment.len() as u16).to_le_bytes());
|
||||
zip.extend(comment);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({"name": "notes.bin", "dataBase64": vectorcraft_format::base64_encode(&zip)})).unwrap();
|
||||
assert_eq!(r["format"].as_str(), Some("docx"));
|
||||
assert_eq!(
|
||||
placed(&s, &r).0,
|
||||
"Line one\nline two\n\na\tb\nc\t\td\ne\t\nf\ntoday\tend\njustified\nrtl",
|
||||
"blank paragraphs at the end are left out"
|
||||
);
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
|
||||
let paras = t.paragraph_styles();
|
||||
assert_eq!(paras[paras.len() - 2].justify, vectorcraft_doc::Justify::JustifyRight, "the last line of right-to-left justified text at the right");
|
||||
let last = paras.last().cloned().unwrap();
|
||||
// The start indent of a right-to-left paragraph is its right indent.
|
||||
assert_eq!(
|
||||
(last.justify, last.left_indent, last.right_indent, last.direction),
|
||||
(vectorcraft_doc::Justify::Left, 0.0, 20.0, Some(vectorcraft_doc::ParaDirection::RightToLeft))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_docx_fails_cleanly() {
|
||||
let mut s = session();
|
||||
let place = |s: &mut Session, name: &str, bytes: &[u8]| {
|
||||
s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).map_err(|e| e.to_string())
|
||||
};
|
||||
let store = |files: &[(&str, &[u8])]| cmd::fileio::zip::store(files).unwrap();
|
||||
let document = |body: &str| format!(r#"<w:document xmlns:w="{W_NS}"><w:body>{body}</w:body></w:document>"#);
|
||||
assert!(place(&mut s, "a.docx", b"Plain words, not a package").is_err(), "a .docx file that isn't a ZIP archive");
|
||||
let e = place(&mut s, "a.docx", &store(&[("readme.txt", b"x")])).unwrap_err();
|
||||
assert!(e.contains("no main document part"), "{e}");
|
||||
let e = place(&mut s, "a.zip", &store(&[("readme.txt", b"x")])).unwrap_err();
|
||||
assert!(e.contains("not a format VectorCraft reads"), "a ZIP archive of something else isn't read as a Word document: {e}");
|
||||
let sheet = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"/>"#;
|
||||
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", sheet.as_bytes())])).unwrap_err();
|
||||
assert!(e.contains("not a WordprocessingML document"), "{e}");
|
||||
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", b"<w:document><w:body>")])).unwrap_err();
|
||||
assert!(e.contains("not well-formed XML"), "{e}");
|
||||
let e = place(&mut s, "a.docx", &store(&[("word/document.xml", document("<w:p/>").as_bytes())])).unwrap_err();
|
||||
assert!(e.contains("no text to place"), "{e}");
|
||||
// A file whose headers understate what it unpacks to, and one whose headers say it is too large.
|
||||
let bomb = vec![b' '; (16 << 20) + 1];
|
||||
let e = place(&mut s, "a.docx", &deflated_zip(&[("word/document.xml", &bomb)], Some(100))).unwrap_err();
|
||||
assert!(e.contains("too large to place"), "{e}");
|
||||
let e = place(&mut s, "a.docx", &deflated_zip(&[("word/document.xml", b"<w:document/>")], Some(17 << 20))).unwrap_err();
|
||||
assert!(e.contains("too large to place"), "{e}");
|
||||
let many: Vec<(String, &[u8])> = (0..10_001).map(|i| (format!("f{i}"), &b""[..])).collect();
|
||||
let e = place(&mut s, "a.docx", &store(&many.iter().map(|(n, b)| (n.as_str(), *b)).collect::<Vec<_>>())).unwrap_err();
|
||||
assert!(e.contains("more than 10000 files"), "{e}");
|
||||
// A changed byte fails the file's CRC.
|
||||
let mut changed = store(&[("word/document.xml", document("<w:p><w:r><w:t>words</w:t></w:r></w:p>").as_bytes())]);
|
||||
let at = changed.windows(5).position(|w| w == b"words").unwrap();
|
||||
changed[at] = b'W';
|
||||
let e = place(&mut s, "a.docx", &changed).unwrap_err();
|
||||
assert!(e.contains("damaged"), "{e}");
|
||||
// A stored file whose directory record gives more packed bytes than unpacked ones.
|
||||
let mut longer = store(&[("word/document.xml", document("<w:p><w:r><w:t>words</w:t></w:r></w:p>").as_bytes()), ("pad", &[0; 64])]);
|
||||
let central = longer.windows(4).position(|w| w == 0x0201_4b50u32.to_le_bytes()).unwrap();
|
||||
let packed = u32::from_le_bytes(longer[central + 20..central + 24].try_into().unwrap()) + 64;
|
||||
longer[central + 20..central + 24].copy_from_slice(&packed.to_le_bytes());
|
||||
let e = place(&mut s, "a.docx", &longer).unwrap_err();
|
||||
assert!(e.contains("damaged"), "{e}");
|
||||
// Containers nested past the depth read are skipped, and a part nested deeper still is refused.
|
||||
let nested = |n: usize| {
|
||||
let deep = format!("{}<w:p><w:r><w:t>deep</w:t></w:r></w:p>{}", "<w:sdt><w:sdtContent>".repeat(n), "</w:sdtContent></w:sdt>".repeat(n));
|
||||
store(&[("word/document.xml", document(&format!("{deep}<w:p><w:r><w:t>shallow</w:t></w:r></w:p>")).as_bytes())])
|
||||
};
|
||||
let r = place(&mut s, "a.docx", &nested(100)).unwrap();
|
||||
assert_eq!(placed(&s, &r).0, "shallow");
|
||||
let e = place(&mut s, "a.docx", &nested(5000)).unwrap_err();
|
||||
assert!(e.contains("nests elements more than 256 deep"), "{e}");
|
||||
// A font name is cut to 256 bytes.
|
||||
let long = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{}"/></w:rPr><w:t>font</w:t></w:r></w:p>"#, "é".repeat(1000));
|
||||
let r = place(&mut s, "a.docx", &store(&[("word/document.xml", document(&long).as_bytes())])).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
|
||||
assert_eq!(t.runs[0].style.font_family, "é".repeat(128));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_reads_what_open_reads_and_text_files() {
|
||||
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS].concat());
|
||||
use cmd::fileio::{DOCX_EXTS, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS, DOCX_EXTS].concat());
|
||||
let filters: Vec<_> = cmd::fileio::place_filters().collect();
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("RTF", RTF_EXTS))));
|
||||
assert_eq!(filters.get(filters.len() - 2), Some(&("Text", TEXT_EXTS)));
|
||||
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf")), "Open doesn't read text or RTF files");
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Word", DOCX_EXTS))));
|
||||
assert_eq!(filters.get(filters.len() - 3..), Some(&[("Text", TEXT_EXTS), ("RTF", RTF_EXTS), ("Word", DOCX_EXTS)][..]));
|
||||
assert!(
|
||||
!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf") || exts.contains(&"docx")),
|
||||
"Open doesn't read text, RTF or Word files"
|
||||
);
|
||||
}
|
||||
@@ -227,3 +227,76 @@ fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
|
||||
assert_eq!(text(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
|
||||
/// A file whose type holds a size or scale of 0 or less or a negative leading opens with the
|
||||
/// default size and scale and Auto leading, and a size, leading, baseline shift or scale past
|
||||
/// `MAX_COORD` opens with `MAX_COORD`, in the layers, in symbols and in character styles. A
|
||||
/// leading of 0, a size below 0.1 pt and scales below 1% stay as saved.
|
||||
#[test]
|
||||
fn a_file_with_type_sizes_out_of_range_opens_with_them_in_range() {
|
||||
let mut s = session();
|
||||
let p = json!({"x": 40, "y": 60, "size": 20, "text": "Line one\nLine two\nLine three"});
|
||||
let id = NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 9, "end": 18, "size": 30})).unwrap();
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 18, "end": 28, "size": 40})).unwrap();
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
let [zero, huge, kept] = t.runs.as_mut_slice() else { panic!("not three runs") };
|
||||
let (z, h, k) = (&mut zero.style, &mut huge.style, &mut kept.style);
|
||||
(z.size, z.h_scale, z.v_scale, z.baseline_shift, z.leading) = (0.0, -100.0, 0.0, -f64::MAX, Some(-50.0));
|
||||
(h.size, h.h_scale, h.v_scale, h.baseline_shift, h.leading) = (f64::MAX, f64::MAX, f64::MAX, f64::MAX, Some(f64::MAX));
|
||||
(k.size, k.h_scale, k.v_scale, k.baseline_shift, k.leading) = (0.001, 0.5, 0.5, 5000.0, Some(0.0));
|
||||
let art = d.node(id).unwrap().clone();
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Lines".into(), art: Arc::new(art) });
|
||||
let attrs = json!({"size": 0, "h_scale": 1e9, "v_scale": -1, "baseline_shift": -1e9, "leading": -1});
|
||||
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Broken".into(), attrs: attrs.as_object().unwrap().clone() });
|
||||
let attrs = json!({"size": 40, "leading": null});
|
||||
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Auto".into(), attrs: attrs.as_object().unwrap().clone() });
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
|
||||
s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": data})).unwrap();
|
||||
let dims = |t: &TextObject| {
|
||||
t.runs.iter().map(|r| (r.style.size, r.style.h_scale, r.style.v_scale, r.style.baseline_shift, r.style.leading)).collect::<Vec<_>>()
|
||||
};
|
||||
let max = vectorcraft_doc::MAX_COORD;
|
||||
let want = vec![(12.0, 100.0, 100.0, -max, None), (max, max, max, max, Some(max)), (0.001, 0.5, 0.5, 5000.0, Some(0.0))];
|
||||
assert_eq!(dims(&text(&s, id)), want);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let NodeKind::Text(art) = &doc.symbols[0].art.kind else { panic!("not text") };
|
||||
assert_eq!(dims(art), want);
|
||||
let fixed = json!({"size": 12.0, "h_scale": max, "v_scale": 100.0, "baseline_shift": -max, "leading": null});
|
||||
assert_eq!(doc.char_styles[0].attrs, *fixed.as_object().unwrap());
|
||||
assert_eq!(doc.char_styles[1].attrs, *json!({"size": 40, "leading": null}).as_object().unwrap());
|
||||
// With the sizes kept as saved, the type's bounds were infinite.
|
||||
let b = doc.bounds_of(&[id], false).unwrap();
|
||||
assert!(b.width().is_finite() && b.height().is_finite(), "{b:?}");
|
||||
}
|
||||
|
||||
/// SVG import keeps type sizes outside the Character panel's 0.1 to 1296 pt, and it gives a line
|
||||
/// aligned differently from the one before it an indent, here past the Paragraph panel's 1296 pt.
|
||||
/// A native file of the result opens with the same type.
|
||||
#[test]
|
||||
fn imported_type_with_sizes_outside_the_panels_range_opens_unchanged() {
|
||||
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="3000">
|
||||
<text x="10" y="2000" font-size="2000">Big</text>
|
||||
<text x="10" y="2400" font-size="0.05">tiny</text>
|
||||
<text x="200" y="2600" font-size="12" text-anchor="middle"><tspan x="200" y="2600">One</tspan><tspan x="2900" y="2614.4" text-anchor="start">Two</tspan></text>
|
||||
</svg>"#;
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "sizes.svg", "dataBase64": vectorcraft_format::base64_encode(SVG.as_bytes())})).unwrap();
|
||||
let texts = |s: &Session| {
|
||||
let mut out = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.push((**t).clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let imported = texts(&s);
|
||||
let sizes: Vec<f64> = imported.iter().flat_map(|t| t.runs.iter().map(|r| r.style.size)).collect();
|
||||
assert!(sizes.iter().any(|v| *v > 1296.0) && sizes.iter().any(|v| *v < 0.1), "{sizes:?}");
|
||||
assert!(imported.iter().flat_map(|t| t.para_styles()).any(|p| p.left_indent > 1296.0), "an indent past the panel's");
|
||||
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
|
||||
s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": saved})).unwrap();
|
||||
assert_eq!(texts(&s), imported);
|
||||
}
|
||||
@@ -1149,6 +1149,114 @@ fn datasets_csv_row_errors_name_the_file_line() {
|
||||
assert!(long.contains("line 3:") && long.contains("longer than 255"), "{long}");
|
||||
}
|
||||
|
||||
/// A header cell that names no variable defines one, unbound, in the import's undo step: `#`
|
||||
/// defines a visibility variable, `@` defines an image variable, and a cell without a prefix
|
||||
/// defines a text variable. Importing the file again finds them; a prefix of the other kind, a
|
||||
/// bare prefix and a long name fail and define nothing.
|
||||
#[test]
|
||||
fn datasets_csv_header_prefixes_define_variables() {
|
||||
let mut s = session();
|
||||
let csv = "name,#Shown,@Photo,Title\nA,true,a.png,x";
|
||||
let imp = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
|
||||
assert_eq!(imp["defined"], json!(["Shown", "Photo", "Title"]));
|
||||
assert_eq!((imp["imported"].as_u64(), imp["names"].clone()), (Some(1), json!(["A"])));
|
||||
let listed = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
listed["variables"],
|
||||
json!([
|
||||
{"name": "Shown", "kind": "visibility", "bindings": 0},
|
||||
{"name": "Photo", "kind": "image", "bindings": 0},
|
||||
{"name": "Title", "kind": "text", "bindings": 0},
|
||||
])
|
||||
);
|
||||
let value = |s: &Session, var: &str| s.doc().unwrap().doc.variables.dataset("A").and_then(|d| d.values.get(var)).cloned();
|
||||
assert_eq!(value(&s, "Shown"), Some(vectorcraft_doc::DataValue::Visible(true)));
|
||||
assert_eq!(value(&s, "Photo"), Some(vectorcraft_doc::DataValue::Image("a.png".into())));
|
||||
assert_eq!(value(&s, "Title"), Some(vectorcraft_doc::DataValue::Text("x".into())));
|
||||
|
||||
let again = s.execute("dataset.import", &json!({"csv": "name\t#Shown\t@Photo\tTitle\nA\tfalse\tb.png\ty"})).unwrap();
|
||||
assert_eq!((again["updated"].as_u64(), again["defined"].clone()), (Some(1), json!([])));
|
||||
assert_eq!(value(&s, "Shown"), Some(vectorcraft_doc::DataValue::Visible(false)));
|
||||
|
||||
let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string();
|
||||
let other_kind = error(&mut s, "name,New,#Title\nB,n,true");
|
||||
assert!(other_kind.contains("column 3:") && other_kind.contains("text variable"), "{other_kind}");
|
||||
let twice = error(&mut s, "Fresh,@Fresh\nn,f.png");
|
||||
assert!(twice.contains("column 2:") && twice.contains("text variable"), "{twice}");
|
||||
let bare = error(&mut s, "name,#\nB,true");
|
||||
assert!(bare.contains("column 2:") && bare.contains("names no variable"), "{bare}");
|
||||
let long = error(&mut s, &format!("name,@{}\nB,b.png", "n".repeat(256)));
|
||||
assert!(long.contains("column 2:") && long.contains("longer than 255"), "{long}");
|
||||
assert_eq!(s.doc().unwrap().doc.variables.variables.len(), 3, "a failed import defines nothing");
|
||||
|
||||
// Each import is one undo step, which takes back the variables it defined with its rows.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(value(&s, "Shown"), Some(vectorcraft_doc::DataValue::Visible(true)));
|
||||
assert_eq!(s.doc().unwrap().doc.variables.variables.len(), 3);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let vars = &s.doc().unwrap().doc.variables;
|
||||
assert!(vars.variables.is_empty() && vars.datasets.is_empty());
|
||||
}
|
||||
|
||||
/// A header cell that is a variable's exact name names it, even when the name starts with `#`,
|
||||
/// `@` or `%`, so a file `dataset.export` wrote imports back without defining anything.
|
||||
#[test]
|
||||
fn datasets_csv_exact_names_win_over_prefixes() {
|
||||
let mut s = session();
|
||||
for (name, kind) in [("#Shown", "text"), ("@Photo", "visibility"), ("%Chart", "graph"), ("Shown", "visibility")] {
|
||||
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
|
||||
}
|
||||
let values = json!({"#Shown": "hello", "@Photo": true, "%Chart": ",Q\nX,3", "Shown": false});
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": values})).unwrap();
|
||||
let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
|
||||
assert!(csv.starts_with("name,#Shown,@Photo,%Chart,Shown\n"), "{csv}");
|
||||
s.execute("dataset.delete", &json!({"names": ["A"]})).unwrap();
|
||||
let imp = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
|
||||
assert_eq!((imp["imported"].as_u64(), imp["defined"].clone()), (Some(1), json!([])));
|
||||
let value = |var: &str| s.doc().unwrap().doc.variables.dataset("A").and_then(|d| d.values.get(var)).cloned();
|
||||
assert_eq!(value("#Shown"), Some(vectorcraft_doc::DataValue::Text("hello".into())));
|
||||
assert_eq!(value("@Photo"), Some(vectorcraft_doc::DataValue::Visible(true)));
|
||||
assert_eq!(value("%Chart"), Some(vectorcraft_doc::DataValue::Graph(",Q\nX,3".into())));
|
||||
assert_eq!(value("Shown"), Some(vectorcraft_doc::DataValue::Visible(false)));
|
||||
assert_eq!(s.doc().unwrap().doc.variables.variables.len(), 4);
|
||||
}
|
||||
|
||||
/// A `%` header cell that isn't a variable's name (graph data files) is refused with its column,
|
||||
/// and the import defines nothing.
|
||||
#[test]
|
||||
fn datasets_csv_refuses_graph_data_file_columns() {
|
||||
let mut s = session();
|
||||
let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string();
|
||||
let named = error(&mut s, "name,Title,%Sales\nA,x,sales.txt");
|
||||
assert!(named.contains("column 3:") && named.contains("`%Sales`"), "{named}");
|
||||
let unnamed = error(&mut s, "%Sales\tTitle\nsales.txt\tx");
|
||||
assert!(unnamed.contains("column 1:") && unnamed.contains("`%Sales`"), "{unnamed}");
|
||||
let vars = &s.doc().unwrap().doc.variables;
|
||||
assert!(vars.variables.is_empty() && vars.datasets.is_empty());
|
||||
}
|
||||
|
||||
/// While the variables are locked, an import is refused when a header cell would define a
|
||||
/// variable, as New Variable is, and a file whose headers all name variables imports.
|
||||
#[test]
|
||||
fn locked_variables_refuse_header_cells_that_define_variables() {
|
||||
let mut s = session();
|
||||
s.execute("variable.define", &json!({"name": "Shown", "kind": "visibility"})).unwrap();
|
||||
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
|
||||
s.execute("variable.lock", &json!({"locked": true})).unwrap();
|
||||
for csv in ["name,#Shown,Title,New\nA,true,x,n", "@Photo\nb.png", "name,#Fresh\nA,true"] {
|
||||
let e = s.execute("dataset.import", &json!({"csv": csv})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::Disabled(cmd, why) if cmd == "dataset.import" && why == "variables are locked"), "{csv}: {e}");
|
||||
}
|
||||
let vars = &s.doc().unwrap().doc.variables;
|
||||
assert!(vars.variables.len() == 2 && vars.datasets.is_empty(), "a refused import defines and imports nothing");
|
||||
|
||||
let imp = s.execute("dataset.import", &json!({"csv": "name,#Shown,Title\nA,true,x"})).unwrap();
|
||||
assert_eq!((imp["imported"].as_u64(), imp["defined"].clone()), (Some(1), json!([])));
|
||||
s.execute("variable.lock", &json!({"locked": false})).unwrap();
|
||||
let imp = s.execute("dataset.import", &json!({"csv": "name,New\nB,n"})).unwrap();
|
||||
assert_eq!(imp["defined"], json!(["New"]));
|
||||
}
|
||||
|
||||
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
|
||||
#[test]
|
||||
fn datasets_csv_round_trips_and_rejects_bad_input() {
|
||||
@@ -1185,12 +1293,11 @@ fn datasets_csv_round_trips_and_rejects_bad_input() {
|
||||
assert!(!s.doc().unwrap().doc.node(r).unwrap().visible);
|
||||
assert_eq!(image(&s, img).link.as_ref().map(|l| l.path.as_str()), Some(blue_path.as_str()));
|
||||
|
||||
// Re-import updates in place; unknown variable and bad visibility are errors.
|
||||
// Re-import updates in place and defines nothing; a bad visibility value is an error.
|
||||
let again = s.execute("dataset.import", &json!({"csv": csv})).unwrap();
|
||||
assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2)));
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err());
|
||||
assert_eq!(again["defined"], json!([]));
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err());
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "no variable named Title");
|
||||
|
||||
let out = dir.file("sets.csv");
|
||||
s.execute("dataset.export", &json!({"path": out})).unwrap();
|
||||
|
||||
@@ -262,6 +262,36 @@ fn slices_give_one_image_each_and_a_page() {
|
||||
assert_eq!((one_slice["width"].as_f64(), one_slice["height"].as_f64()), (layout[0]["width"].as_f64(), layout[0]["height"].as_f64()));
|
||||
}
|
||||
|
||||
/// Save for Web writes the image of a slice named after a Windows device (`aux`, `NUL`, `COM1`…)
|
||||
/// with a `-` after the device name, on every platform.
|
||||
#[test]
|
||||
fn a_slice_named_after_a_windows_device_gets_a_dash() {
|
||||
let mut s = session(300.0, 200.0);
|
||||
let a = rect(&mut s, [20.0, 20.0, 100.0, 60.0], "#ff8800");
|
||||
run(&mut s, "object.slice.make", json!({"ids": [a]}));
|
||||
run(&mut s, "object.slice.options", json!({"name": "aux"}));
|
||||
let b = run(&mut s, "object.slice.create", json!({"x": 150, "y": 100, "width": 100, "height": 50}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.slice.select", json!({"slices": [b]}));
|
||||
run(&mut s, "object.slice.options", json!({"name": "aux-"}));
|
||||
run(&mut s, "object.slice.select", json!({"slices": [a, b]}));
|
||||
let mut names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"slices": "selected"}))).into_iter().map(|(n, _)| n).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["images/aux- 2.gif", "images/aux-.gif"]);
|
||||
}
|
||||
|
||||
/// Without a path the files are named after the document's title, with the characters Windows does
|
||||
/// not allow in a file name as `-` and a `-` after a Windows device name, on every platform.
|
||||
#[test]
|
||||
fn files_named_after_the_title_follow_windows_naming_rules() {
|
||||
for (title, page, image) in [("a|b?c*\"<d>", "a-b-c---d-.html", "images/a-b-c---d-.gif"), ("nul", "nul-.html", "images/nul-.gif")] {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 40, "height": 30, "name": title}));
|
||||
rect(&mut s, [0.0, 0.0, 20.0, 30.0], "#ff0000");
|
||||
let names: Vec<String> = files(&run(&mut s, "document.exportForWeb", json!({"output": "html"}))).into_iter().map(|(n, _)| n).collect();
|
||||
assert_eq!(names, [page, image], "{title}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_are_written_beside_the_path() {
|
||||
let dir = vectorcraft_testkit::temp_dir("webexport");
|
||||
@@ -278,6 +308,40 @@ fn files_are_written_beside_the_path() {
|
||||
assert_eq!(r["bytes"].as_u64().unwrap(), std::fs::metadata(&page).unwrap().len() + std::fs::metadata(dir.join("images/page.png")).unwrap().len());
|
||||
}
|
||||
|
||||
/// A 64×32 PNG whose header reads (so it places at its size) but whose pixel data is cut short.
|
||||
fn cut_png() -> Vec<u8> {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
png.truncate(idat + 8);
|
||||
png
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_says_when_an_image_could_not_be_decoded() {
|
||||
let undecoded =
|
||||
|r: &Value| r["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))));
|
||||
let mut s = two_colours();
|
||||
let r = run(&mut s, "document.exportForWeb", json!({}));
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert_eq!(run(&mut s, "document.exportForWeb.preview", json!({}))["warnings"], json!([]));
|
||||
// The renderer can't decode the placed image and draws nothing in its place.
|
||||
run(&mut s, "file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&cut_png())}));
|
||||
for format in ["gif", "jpg", "png8", "png24"] {
|
||||
let r = run(&mut s, "document.exportForWeb", json!({"format": format}));
|
||||
assert!(undecoded(&r), "{format}: {}", r["warnings"]);
|
||||
let r = run(&mut s, "document.exportForWeb.preview", json!({"format": format}));
|
||||
assert!(undecoded(&r), "{format} preview: {}", r["warnings"]);
|
||||
}
|
||||
let dir = vectorcraft_testkit::temp_dir("webexport-undecoded");
|
||||
let page = dir.join("page.html").to_string_lossy().to_string();
|
||||
let r = run(&mut s, "document.exportForWeb", json!({"path": page, "output": "html"}));
|
||||
assert!(undecoded(&r), "{r}");
|
||||
assert!(dir.join("images").join("page.gif").exists(), "the files are still written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_size_region_and_metadata() {
|
||||
let mut s = two_colours();
|
||||
|
||||
@@ -368,6 +368,7 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
|
||||
"kerning",
|
||||
"writing-mode",
|
||||
"font-weight",
|
||||
"font-variation-settings",
|
||||
"text-anchor",
|
||||
"side",
|
||||
"path",
|
||||
@@ -393,6 +394,7 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
|
||||
"onclick",
|
||||
"onload",
|
||||
]);
|
||||
let axes = prop::sample::select(vec!["wght", "wdth", "opsz", "wd", "slnt!", ""]);
|
||||
let value = prop_oneof![
|
||||
arb_num(),
|
||||
prop::collection::vec(arb_num(), 0..7).prop_map(|v| v.join(" ")),
|
||||
@@ -401,6 +403,9 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
|
||||
prop::collection::vec((prop::sample::select(vec!["M", "L", "C", "Q", "A", "Z", "H", "V", "S", "T", "m", "a"]), arb_num()), 0..12)
|
||||
.prop_map(|v| v.iter().map(|(c, n)| format!("{c}{n} {n}")).collect::<Vec<_>>().join(" ")),
|
||||
prop::collection::vec(arb_num(), 0..2).prop_map(|v| format!("{}%", v.join(""))),
|
||||
// Variable font axis settings, well formed or not.
|
||||
prop::collection::vec((axes, arb_num()), 0..5).prop_map(|v| v.iter().map(|(t, n)| format!("'{t}' {n}")).collect::<Vec<_>>().join(", ")),
|
||||
prop::sample::select(vec!["normal", "initial", "unset"]).prop_map(str::to_string),
|
||||
// Colours (Lab too), mask options and links.
|
||||
prop::collection::vec(arb_num(), 0..4).prop_map(|v| format!("lab({})", v.join(" "))),
|
||||
prop::sample::select(vec!["noclip invert", "invert", "noclip", "alpha", "luminance", "url(#e)", "#e", "#zz", "none", "currentColor"])
|
||||
@@ -1207,10 +1212,21 @@ proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n"])) {
|
||||
fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n", "name,#Show,@Photo,New\n", "#Shown\t@Title\tChart\n", "name,%Chart,#\n"])) {
|
||||
datasets("data set CSV garbage", json!({"csv": format!("{head}{s}")}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixed_dataset_headers_never_panic(
|
||||
cells in prop::collection::vec((prop::sample::select(vec!["", "#", "@", "%", "#@", " # "]), "[A-Za-z #@%\"]{0,8}"), 1..8),
|
||||
tabs in any::<bool>(),
|
||||
body in ".{0,200}",
|
||||
) {
|
||||
let sep = if tabs { "\t" } else { "," };
|
||||
let header: Vec<String> = cells.iter().map(|(prefix, name)| format!("{prefix}{name}")).collect();
|
||||
datasets("prefixed data set CSV header", json!({"csv": format!("{}\n{body}", header.join(sep))}))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dataset_csv_bytes_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..300)) {
|
||||
datasets("data set CSV bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?;
|
||||
@@ -1361,6 +1377,54 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-bit TIFFs compressed with CCITT Group 3: modified Huffman (Compression 2) in three strips, T.4
|
||||
/// (Compression 3) with two-dimensional rows, fill bits, six EOLs at the end and each byte's lowest
|
||||
/// bit first, and T.4 two-dimensional rows in 16 × 16 tiles.
|
||||
fn group3_samples() -> Vec<Vec<u8>> {
|
||||
let rows: Vec<Vec<bool>> = (0..6).map(|y| (0..40).map(|x| (x * (y + 1) / 3 + y) % 4 < 2).collect()).collect();
|
||||
let tiff = |compression, t4_options, fill_order, rows_per_strip, tile| {
|
||||
let rtc = compression == 3;
|
||||
let g = vectorcraft_testkit::tiff::Group3 { rows: &rows, compression, t4_options, photometric: 0, fill_order, rows_per_strip, tile, rtc };
|
||||
vectorcraft_testkit::tiff::group3(&g)
|
||||
};
|
||||
vec![tiff(2, 0, 1, 2, None), tiff(3, 5, 2, 6, None), tiff(3, 1, 1, 6, Some((16, 16)))]
|
||||
}
|
||||
|
||||
/// [`group3_samples`] place as images.
|
||||
#[test]
|
||||
fn the_group3_samples_place() {
|
||||
for bytes in group3_samples() {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Mutated Group 3 TIFFs (directory values, strip offsets and counts, the coded rows) never
|
||||
/// crash reading or placing them.
|
||||
#[test]
|
||||
fn mutated_group3_tiffs_place_without_panics(which in 0usize..3, cut in 0usize..400, edits in prop::collection::vec((0usize..400, any::<u8>()), 0..12)) {
|
||||
let mut bytes = group3_samples().swap_remove(which);
|
||||
for (at, b) in edits {
|
||||
if let Some(x) = bytes.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
bytes.truncate(cut.max(8));
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
|
||||
let _ = no_panic(s.execute("file.place.info", &p));
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SVG saved with hidden layers and editing data ----------
|
||||
|
||||
/// The rich document with a hidden layer, saved as SVG with its editing data.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import
|
||||
//! Options, place as area type or fail with an error, and so do hostile RTF files.
|
||||
//! Options, place as area type or fail with an error, and so do hostile RTF files and Word
|
||||
//! documents.
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
@@ -59,6 +60,123 @@ proptest! {
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_docx_places_without_panics(
|
||||
body in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..120), 3 => prop::collection::vec(arb_docx_paragraph(), 0..12)],
|
||||
styles in prop_oneof![1 => prop::collection::vec(arb_docx_token(), 0..40), 3 => prop::collection::vec(arb_docx_style(), 0..6)],
|
||||
changes in prop_oneof![
|
||||
3 => Just(vec![]),
|
||||
1 => prop::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..3),
|
||||
],
|
||||
name in prop::sample::select(vec!["fuzz.docx", "fuzz.bin"]),
|
||||
) {
|
||||
vectorcraft_text::FontDb::global().set_system_fallback(false);
|
||||
let w = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
|
||||
let document = format!("<w:document {w}><w:body>{}</w:body></w:document>", body.concat());
|
||||
let styles = format!("<w:styles {w}>{}</w:styles>", styles.concat());
|
||||
let files = [("word/document.xml", document.as_bytes()), ("word/styles.xml", styles.as_bytes())];
|
||||
let mut data = vectorcraft_engine::cmd::fileio::zip::store(&files).unwrap();
|
||||
// A changed byte anywhere: in a header, a size, the directory or the XML.
|
||||
for (at, byte) in changes {
|
||||
let i = at.index(data.len());
|
||||
data[i] = byte;
|
||||
}
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
|
||||
let _ = s.execute("file.place.info", &p);
|
||||
let _ = s.execute("file.place", &p);
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-formed paragraph, or a table row of two such paragraphs.
|
||||
fn arb_docx_paragraph() -> impl Strategy<Value = String> {
|
||||
prop_oneof![
|
||||
3 => arb_docx_para(),
|
||||
1 => (arb_docx_para(), arb_docx_para()).prop_map(|(a, b)| format!("<w:tbl><w:tr><w:tc>{a}</w:tc><w:tc>{b}</w:tc></w:tr></w:tbl>")),
|
||||
]
|
||||
}
|
||||
|
||||
/// A well-formed paragraph: properties and runs of empty elements and text.
|
||||
fn arb_docx_para() -> impl Strategy<Value = String> {
|
||||
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4);
|
||||
(leaves(), prop::collection::vec((leaves(), arb_docx_leaf()), 0..6)).prop_map(|(ppr, runs)| {
|
||||
let runs: String = runs.iter().map(|(rpr, t)| format!("<w:r><w:rPr>{}</w:rPr><w:t>{t}</w:t></w:r>", rpr.concat())).collect();
|
||||
format!("<w:p><w:pPr>{}</w:pPr>{runs}</w:p>", ppr.concat())
|
||||
})
|
||||
}
|
||||
|
||||
/// Elements the Word document reader knows that hold others, opened or closed by
|
||||
/// [`arb_docx_token`].
|
||||
const DOCX_TAGS: &str = "p r t pPr rPr tbl tr tc sdt sdtContent hyperlink del ins style docDefaults rPrDefault pPrDefault";
|
||||
|
||||
/// Empty elements the Word document reader knows.
|
||||
const DOCX_EMPTY: &str = "b i caps smallCaps strike dstrike vanish tab ptab br cr noBreakHyphen softHyphen bidi p";
|
||||
|
||||
/// Well-formed document defaults, or a paragraph or character style `S` or `C` (based on `S` or
|
||||
/// not), with properties of empty elements.
|
||||
fn arb_docx_style() -> impl Strategy<Value = String> {
|
||||
let leaves = || prop::collection::vec(arb_docx_leaf(), 0..4).prop_map(|l| l.concat());
|
||||
let kind = prop::sample::select(vec![("paragraph", "S"), ("character", "C"), ("paragraph", "Normal\" w:default=\"1")]);
|
||||
prop_oneof![
|
||||
(leaves(), leaves()).prop_map(|(r, p)| format!(
|
||||
"<w:docDefaults><w:rPrDefault><w:rPr>{r}</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr>{p}</w:pPr></w:pPrDefault></w:docDefaults>"
|
||||
)),
|
||||
(kind, any::<bool>(), leaves(), leaves()).prop_map(|((t, id), based, r, p)| {
|
||||
let based = if based { "<w:basedOn w:val=\"S\"/>" } else { "" };
|
||||
format!("<w:style w:type=\"{t}\" w:styleId=\"{id}\">{based}<w:rPr>{r}</w:rPr><w:pPr>{p}</w:pPr></w:style>")
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// Pieces of WordprocessingML the Word document reader knows (paragraphs, runs, properties,
|
||||
/// tables, fields, styles), a cut-off tag, entities and text.
|
||||
fn arb_docx_token() -> impl Strategy<Value = String> {
|
||||
let tags = prop::sample::select(DOCX_TAGS.split_whitespace().collect::<Vec<_>>());
|
||||
let open = (tags, any::<bool>()).prop_map(|(t, end)| format!("<{}w:{t}>", if end { "/" } else { "" }));
|
||||
let styles = prop::sample::select(vec!["<w:style w:type=\"paragraph\" w:styleId=\"S\">", "<w:style w:type=\"character\" w:styleId=\"C\">"]);
|
||||
prop_oneof![open, styles.prop_map(str::to_string), arb_docx_leaf(), Just("<w:t".to_string())]
|
||||
}
|
||||
|
||||
/// An empty WordprocessingML element the reader knows, with or without a value in or out of
|
||||
/// range, entities or text.
|
||||
fn arb_docx_leaf() -> impl Strategy<Value = String> {
|
||||
let empty = prop::sample::select(DOCX_EMPTY.split_whitespace().collect::<Vec<_>>());
|
||||
let valued = prop::sample::select(vec![
|
||||
("pStyle", "val", "S"),
|
||||
("rStyle", "val", "C"),
|
||||
("basedOn", "val", "S"),
|
||||
("sz", "val", "-7"),
|
||||
("sz", "val", "1e400"),
|
||||
("sz", "val", "12pt"),
|
||||
("position", "val", "99999999999"),
|
||||
("color", "val", "FF00"),
|
||||
("color", "val", "auto"),
|
||||
("u", "val", "none"),
|
||||
("vertAlign", "val", "superscript"),
|
||||
("jc", "val", "distribute"),
|
||||
("jc", "val", "end"),
|
||||
("ind", "hanging", "-360"),
|
||||
("ind", "left", "3in"),
|
||||
("spacing", "after", "x"),
|
||||
("fldChar", "fldCharType", "begin"),
|
||||
("fldChar", "fldCharType", "separate"),
|
||||
("fldChar", "fldCharType", "end"),
|
||||
("rFonts", "asciiTheme", "majorHAnsi"),
|
||||
("rFonts", "ascii", ""),
|
||||
("br", "type", "page"),
|
||||
("b", "val", "0"),
|
||||
]);
|
||||
prop_oneof![
|
||||
empty.prop_map(|e| format!("<w:{e}/>")),
|
||||
valued.prop_map(|(e, a, v)| format!("<w:{e} w:{a}=\"{v}\"/>")),
|
||||
Just("&😀<".to_string()),
|
||||
"[a-z ]{0,6}",
|
||||
]
|
||||
}
|
||||
|
||||
/// Control words the RTF reader knows, and one it doesn't.
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Colored 3D lighting remains a live command and survives native and vector output.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
const EFFECTS: [&str; 4] = ["threeD.revolve", "threeD.extrude", "threeD.inflate", "threeD.rotate"];
|
||||
|
||||
fn setup(effect: &str) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
let p = json!({"effect":effect,"item":null,"params":{"rotationX":0,"rotationY":0,"rotationZ":0,"lightHeight":90}});
|
||||
s.check_params("effect.apply", &p).unwrap();
|
||||
s.execute("effect.apply", &p).unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
fn params(s: &Session, id: NodeId) -> &Value {
|
||||
&doc(s).node(id).unwrap().appearance.effects[0].params
|
||||
}
|
||||
fn contains_color(n: &Node, hex: &str) -> bool {
|
||||
n.appearance.items.iter().any(|i| i.paint().color().is_some_and(|c| c.to_hex() == hex))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|n| contains_color(n, hex)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_light_colors_save_reopen_undo_and_expand_without_editing_source() {
|
||||
for effect in EFFECTS {
|
||||
let (mut s, id) = setup(effect);
|
||||
let original = doc(&s).clone();
|
||||
let path = doc(&s).node(id).unwrap().path_data().unwrap().clone();
|
||||
assert_eq!(params(&s, id)["lightColor"], json!("#ffffff"));
|
||||
let change = json!({"index":0,"item":null,"params":{"lightColor":"#ff0000"}});
|
||||
s.check_params("effect.setParams", &change).unwrap();
|
||||
s.execute("effect.setParams", &change).unwrap();
|
||||
assert_eq!(doc(&s).node(id).unwrap().path_data(), Some(&path));
|
||||
let red = doc(&s).clone();
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&red, false)).unwrap();
|
||||
assert_eq!(reopened.node(id).unwrap().appearance.effects[0].params["lightColor"], json!("#ff0000"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &original);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &red);
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap();
|
||||
assert!(text.contains("<path") && !text.contains("<image"), "{effect}");
|
||||
if effect == "threeD.rotate" {
|
||||
assert!(text.contains("#ff4040"), "front plane is white under red light and neutral 25% ambient: {text}");
|
||||
}
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|s| s == b"/Subtype /Image"), "{effect}");
|
||||
assert_eq!(doc(&s), &red, "exports leave the live document intact");
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert!(doc(&s).node(id).unwrap().appearance.effects.is_empty());
|
||||
if effect == "threeD.rotate" {
|
||||
assert!(contains_color(doc(&s).node(id).unwrap(), "#ff4040"));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &red);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_light_color_is_rejected_without_mutating_any_effect() {
|
||||
for effect in EFFECTS {
|
||||
let (mut s, _) = setup(effect);
|
||||
let before = doc(&s).clone();
|
||||
for color in [json!("#ff0"), json!("#ffffff00"), json!("invalid"), json!(42), json!(null), json!([1, 0, 0])] {
|
||||
let p = json!({"index":0,"item":null,"params":{"lightColor":color}});
|
||||
assert!(s.execute("effect.setParams", &p).is_err(), "{effect}: {p}");
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
let p = json!({"index":0,"params":{"lightColour":"#ff0000"}});
|
||||
assert!(s.check_params("effect.setParams", &p).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn capped_profile() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M200 120L200 280"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.revolve","item":null,"params":{
|
||||
"offset":70,"rotationX":-25,"segments":24,"ambient":20,"lightIntensity":65,"lightColor":"#ff0000"
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> vectorcraft_render::Rendered {
|
||||
vectorcraft_render::Renderer::new().render(
|
||||
d,
|
||||
400,
|
||||
400,
|
||||
kurbo::Affine::IDENTITY,
|
||||
&vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() },
|
||||
)
|
||||
}
|
||||
|
||||
fn vector_exports(s: &mut Session) -> String {
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
|
||||
assert!(svg.contains("<linearGradient") && !svg.contains("<image"));
|
||||
assert!(
|
||||
svg.split(" d=\"").skip(1).any(|d| d.split('"').next().is_some_and(|d| d.contains('C'))),
|
||||
"continuous curved cap boundaries survive export"
|
||||
);
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|s| s == b"/Subtype /Image"));
|
||||
svg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_revolve_smooth_shading_and_default_caps_work_together_and_expand() {
|
||||
let (mut s, id) = capped_profile();
|
||||
assert_eq!(params(&s, id)["cap"], true);
|
||||
assert!(params(&s, id).get("smoothShading").is_none());
|
||||
let before = render(doc(&s));
|
||||
let source = doc(&s).node(id).unwrap().path_data().unwrap().clone();
|
||||
vector_exports(&mut s);
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"cap":false}})).unwrap();
|
||||
assert_ne!(render(doc(&s)).pixels, before.pixels, "Cap changes the open profile's ends");
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"cap":true}})).unwrap();
|
||||
assert_eq!(doc(&s).node(id).unwrap().path_data(), Some(&source));
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let expanded = render(doc(&s));
|
||||
let mut shades = std::collections::BTreeSet::new();
|
||||
let mut samples = 0;
|
||||
for y in (4..396).step_by(3) {
|
||||
for x in (4..396).step_by(3) {
|
||||
let p = before.pixel(x, y);
|
||||
let q = expanded.pixel(x, y);
|
||||
if p[3] == 255 && q[3] == 255 {
|
||||
assert!(p[1].abs_diff(51) <= 1 && p[2].abs_diff(51) <= 1, "neutral ambient {p:?}");
|
||||
assert!(p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 3), "expand ({x},{y}): {p:?} vs {q:?}");
|
||||
shades.insert(p[0]);
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(samples > 500 && shades.len() > 40, "smooth colored shading at only 24 segments");
|
||||
vector_exports(&mut s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_smooth_source_gradients_keep_alpha_and_vector_output() {
|
||||
for opacity in [1.0, 0.45] {
|
||||
let (mut s, id) = capped_profile();
|
||||
let solid = render(doc(&s));
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[80,200],"end":[240,200],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":opacity,"midpoint":0.3},
|
||||
{"offset":1,"color":"#808080","opacity":if opacity == 1.0 { 1.0 } else { opacity*0.8 }}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let red = render(doc(&s));
|
||||
if opacity == 1.0 {
|
||||
let vectorcraft_doc::color::Paint::Gradient(source) = doc(&s).node(id).unwrap().appearance.items[0].paint() else {
|
||||
panic!("source gradient")
|
||||
};
|
||||
let mut checked = 0;
|
||||
// Compare the body away from the cap/body antialiasing boundary. This crosses
|
||||
// every radial seam and the shared triangle diagonals in the smooth surface.
|
||||
for y in (170..230).step_by(2) {
|
||||
for x in (65..195).step_by(2) {
|
||||
let white = solid.pixel(x, y);
|
||||
if white[3] == 255 && solid.pixel(x - 1, y)[3] == 255 && solid.pixel(x + 1, y)[3] == 255 {
|
||||
let p = red.pixel(x, y);
|
||||
let t = source.geom.unwrap().param_at(source.gradient.kind, kurbo::Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let (color, _) = source.gradient.sample(t as f32);
|
||||
let expected: Vec<_> = white[..3].iter().zip(color.to_rgb()).map(|(v, c)| (*v as f32 * c).round() as u8).collect();
|
||||
assert_eq!(p[3], 255, "source and light share one antialiased coverage");
|
||||
assert!(
|
||||
p.into_iter().zip(&expected).all(|(a, b)| a.abs_diff(*b) <= 3),
|
||||
"gradient seam at ({x},{y}): {p:?}, expected {expected:?}, solid {white:?}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 1000);
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"lightColor":"#0000ff"}})).unwrap();
|
||||
let blue = render(doc(&s));
|
||||
let mut translucent = 0;
|
||||
for y in (4..396).step_by(7) {
|
||||
for x in (4..396).step_by(7) {
|
||||
let r = red.pixel(x, y);
|
||||
let b = blue.pixel(x, y);
|
||||
assert_eq!(r[3], b[3], "light color preserves source coverage");
|
||||
assert!(
|
||||
r[0].abs_diff(b[2]) <= 2 && r[1].abs_diff(b[1]) <= 2 && r[2].abs_diff(b[0]) <= 2,
|
||||
"RGB channels at ({x},{y}): {r:?} vs {b:?}"
|
||||
);
|
||||
translucent += usize::from(r[3] > 30 && r[3] < 245);
|
||||
}
|
||||
}
|
||||
if opacity < 1.0 {
|
||||
assert!(translucent > 100, "gradient stop opacity is preserved");
|
||||
}
|
||||
let svg = vector_exports(&mut s);
|
||||
assert!(svg.contains("multiply"));
|
||||
if opacity < 1.0 {
|
||||
assert!(svg.contains("<mask"));
|
||||
} else {
|
||||
assert!(!svg.contains("<mask"), "opaque faces use vector clipping without opacity-mask buffers");
|
||||
}
|
||||
let imported = vectorcraft_svg::import(&svg).unwrap();
|
||||
let svg_image = render(&imported);
|
||||
let mut checked = 0;
|
||||
for y in (110..290).step_by(11) {
|
||||
for x in (80..225).step_by(11) {
|
||||
let p = blue.pixel(x, y);
|
||||
let q = svg_image.pixel(x, y);
|
||||
if p[3] > 50 {
|
||||
assert!(p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 8), "SVG ({x},{y}): {p:?} vs {q:?}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 50);
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(render(doc(&s)).pixels, blue.pixels, "gradient expansion keeps the live composition");
|
||||
vector_exports(&mut s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_lights_survive_commands_history_native_save_and_vector_expansion() {
|
||||
let lights = json!([
|
||||
{"color":"#ff0000","intensity":60,"height":90,"rotation":0,"softness":0,"behind":false},
|
||||
{"color":"#0000ff","intensity":40,"height":90,"rotation":0,"softness":0,"behind":false}
|
||||
]);
|
||||
for effect in EFFECTS {
|
||||
let (mut s, id) = setup(effect);
|
||||
let before = doc(&s).clone();
|
||||
let source = doc(&s).node(id).unwrap().path_data().unwrap().clone();
|
||||
let change = json!({"index":0,"item":null,"params":{"ambient":20,"lights":lights}});
|
||||
s.check_params("effect.setParams", &change).unwrap();
|
||||
s.execute("effect.setParams", &change).unwrap();
|
||||
let lit = doc(&s).clone();
|
||||
assert_eq!(params(&s, id)["lights"], lights);
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&lit, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["lights"], lights);
|
||||
assert_eq!(doc(&s).node(id).unwrap().path_data(), Some(&source));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &lit);
|
||||
let live = render(doc(&s));
|
||||
if effect == "threeD.rotate" {
|
||||
assert_eq!(live.pixel(150, 150), [204, 51, 153, 255]);
|
||||
}
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
|
||||
assert!(!svg.contains("<image"));
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|s| s == b"/Subtype /Image"));
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let expanded = render(doc(&s));
|
||||
let mut checked = 0;
|
||||
for y in (100..200).step_by(4) {
|
||||
for x in (100..200).step_by(4) {
|
||||
let p = live.pixel(x, y);
|
||||
let q = expanded.pixel(x, y);
|
||||
if p[3] == 255 && q[3] == 255 {
|
||||
assert!(p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 3), "{effect}: {p:?} vs {q:?}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 100, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_light_lists_are_rejected_atomically_and_null_restores_legacy_controls() {
|
||||
for effect in EFFECTS {
|
||||
let (mut s, id) = setup(effect);
|
||||
let before = doc(&s).clone();
|
||||
for lights in [
|
||||
json!([]),
|
||||
json!(vec![json!({}); 11]),
|
||||
json!(false),
|
||||
json!([null]),
|
||||
json!([{"color":"#f00"}]),
|
||||
json!([{"height":91}]),
|
||||
json!([{"intensity":-1}]),
|
||||
json!([{"softness":"soft"}]),
|
||||
json!([{"behind":1}]),
|
||||
json!([{"colour":"#ffffff"}]),
|
||||
] {
|
||||
let change = json!({"index":0,"item":null,"params":{"lights":lights}});
|
||||
assert!(s.execute("effect.setParams", &change).is_err(), "{effect}: {lights}");
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"lights":[{},{}]}})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"lights":null}})).unwrap();
|
||||
assert!(params(&s, id)["lights"].is_null());
|
||||
assert_eq!(render(doc(&s)).pixels, render(&before).pixels);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_multi_light_color_varies_in_two_directions_and_stays_vector_with_source_gradients() {
|
||||
let (mut s, id) = capped_profile();
|
||||
s.execute(
|
||||
"effect.setParams",
|
||||
&json!({"index":0,"item":null,"params":{"ambient":10,"lights":[
|
||||
{"color":"#ff0000","rotation":0,"height":25,"intensity":70},
|
||||
{"color":"#0000ff","rotation":180,"height":50,"intensity":65}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let solid = render(doc(&s));
|
||||
assert!(solid.pixels.as_chunks::<4>().0.iter().any(|p| u16::from(p[0]) > u16::from(p[2]) + 20 && p[3] == 255));
|
||||
assert!(solid.pixels.as_chunks::<4>().0.iter().any(|p| u16::from(p[2]) > u16::from(p[0]) + 20 && p[3] == 255));
|
||||
for opacity in [1.0, 0.5] {
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[80,200],"end":[240,200],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":opacity},{"offset":1,"color":"#808080","opacity":opacity}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let live = render(doc(&s));
|
||||
if opacity == 1.0 {
|
||||
for y in (175..225).step_by(5) {
|
||||
for x in (75..190).step_by(5) {
|
||||
let a = solid.pixel(x, y);
|
||||
let b = live.pixel(x, y);
|
||||
if a[3] == 255 {
|
||||
let factor = 1.0 - ((f64::from(x) + 0.5 - 80.0) / 160.0).clamp(0.0, 1.0) * (1.0 - 128.0 / 255.0);
|
||||
for c in 0..3 {
|
||||
let expected = (f64::from(a[c]) * factor).round() as u8;
|
||||
assert!(b[c].abs_diff(expected) <= 4, "({x},{y}) {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert!(live.pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 30 && p[3] < 220).count() > 1000);
|
||||
}
|
||||
let svg = vector_exports(&mut s);
|
||||
assert!(svg.contains("screen") && svg.contains("multiply"));
|
||||
let imported = vectorcraft_svg::import(&svg).unwrap();
|
||||
let roundtrip = render(&imported);
|
||||
for y in (150..250).step_by(13) {
|
||||
for x in (90..185).step_by(13) {
|
||||
let a = live.pixel(x, y);
|
||||
let b = roundtrip.pixel(x, y);
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "SVG {a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
let before = doc(&s).clone();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert!(doc(&s).node(id).unwrap().appearance.effects.is_empty());
|
||||
if opacity < 1.0 {
|
||||
assert_eq!(live.pixels, render(doc(&s)).pixels);
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_lighting_has_no_polygon_steps_and_stays_vector_after_expansion() {
|
||||
// Check the export pipeline's continuous shading. The 8-bit canvas is also reviewed
|
||||
// natively at high zoom; it can quantize a short gradient ramp differently at 1:1.
|
||||
let render = |d: &Document| {
|
||||
vectorcraft_render::Renderer::new().render(
|
||||
d,
|
||||
400,
|
||||
400,
|
||||
kurbo::Affine::IDENTITY,
|
||||
&vectorcraft_render::RenderOptions { background: None, artboards: false, precise: true, supersample: true, ..Default::default() },
|
||||
)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
s.execute("path.create", &json!({"d":"M300 200C300 255.228475 255.228475 300 200 300C144.771525 300 100 255.228475 100 200C100 144.771525 144.771525 100 200 100C255.228475 100 300 144.771525 300 200Z"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.inflate","item":null,"params":{
|
||||
"rotationX":0,"rotationY":0,"rotationZ":0,"ambient":10,"volume":50,"lights":[
|
||||
{"color":"#ff0000","rotation":135,"height":35,"intensity":70},
|
||||
{"color":"#0000ff","rotation":-35,"height":25,"intensity":65}
|
||||
]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let live = render(doc(&s));
|
||||
for y in [175, 200, 225] {
|
||||
for x in 145..255 {
|
||||
let a = live.pixel(x, y);
|
||||
let b = live.pixel(x + 1, y);
|
||||
assert_eq!(a[3], 255, "interior seam at ({x},{y})");
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 3), "polygon band at ({x},{y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
|
||||
assert!(svg.contains("linearGradient") && svg.contains("screen") && !svg.contains("<image"));
|
||||
let roundtrip = render(&vectorcraft_svg::import(&svg).unwrap());
|
||||
for y in (110..290).step_by(9) {
|
||||
for x in (110..290).step_by(9) {
|
||||
let a = live.pixel(x, y);
|
||||
let b = roundtrip.pixel(x, y);
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 6), "batched SVG ({x},{y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
let before = doc(&s).clone();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(render(doc(&s)).pixels, live.pixels);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
// A colored source gradient still multiplies the light, keeping stop transparency.
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[100,200],"end":[300,200],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let live = render(doc(&s));
|
||||
assert!((150..=156).contains(&live.pixel(200, 200)[3]), "surface opacity is applied once: {:?}", live.pixel(200, 200));
|
||||
let svg = vector_exports(&mut s);
|
||||
assert!(svg.contains("linearGradient") && svg.contains("screen") && svg.contains("multiply"));
|
||||
let imported = vectorcraft_svg::import(&svg).unwrap();
|
||||
let roundtrip = render(&imported);
|
||||
for y in (155..245).step_by(9) {
|
||||
for x in (155..245).step_by(9) {
|
||||
let a = live.pixel(x, y);
|
||||
let b = roundtrip.pixel(x, y);
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 6), "SVG ({x},{y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
let before = doc(&s).clone();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(render(doc(&s)).pixels, live.pixels);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_gradient_opacity_is_applied_once_even_without_shading() {
|
||||
for effect in &EFFECTS[1..] {
|
||||
let (mut s, _) = setup(effect);
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"shade":false,"lightColor":"#ff8040"}})).unwrap();
|
||||
let opaque = render(doc(&s));
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let image = render(doc(&s));
|
||||
for y in (120..180).step_by(5) {
|
||||
for x in (120..180).step_by(5) {
|
||||
let alpha = image.pixel(x, y)[3];
|
||||
// Preserve the canvas pipeline's edge coverage, then apply source opacity
|
||||
// once. Subpixel seams can have less than full coverage in the 8-bit canvas.
|
||||
let expected = (f32::from(opaque.pixel(x, y)[3]) * 0.6).round() as u8;
|
||||
assert!(alpha.abs_diff(expected) <= 3, "{effect} ({x},{y}): alpha {alpha}, expected {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_colored_inflate_preserves_compound_holes_and_surface_opacity() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
s.execute("path.create", &json!({"d":"M80 80H320V320H80ZM150 150V250H250V150Z"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"item":0,"opacity":60})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.inflate","item":null,"params":{
|
||||
"rotationX":0,"rotationY":0,"rotationZ":0,"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let image = render(doc(&s));
|
||||
assert_eq!(image.pixel(200, 200)[3], 0, "the compound coverage clip preserves holes");
|
||||
assert!((150..=156).contains(&image.pixel(110, 200)[3]), "surface opacity applies once: {:?}", image.pixel(110, 200));
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(image.pixels, render(doc(&s)).pixels);
|
||||
}
|
||||
@@ -740,13 +740,14 @@ fn differ_in_colour(p: &[u8; 4], q: &[u8; 4]) -> bool {
|
||||
p.iter().zip(q).any(|(a, b)| a.abs_diff(b) > SAME_COLOUR)
|
||||
}
|
||||
|
||||
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, and of
|
||||
/// those whose neighbours all differ too, away from type (where one draws an object that the other
|
||||
/// doesn't, not the edges of one drawn a little differently, or type laid out a little
|
||||
/// differently: a line the page sets in pieces is laid out afresh as one).
|
||||
/// How much two documents draw differently: the share (0 to 1) of the pixels that differ, of those
|
||||
/// that differ away from type, and of those whose neighbors all differ too, away from type (where
|
||||
/// one draws an object that the other doesn't, not the edges of one drawn a little differently, or
|
||||
/// type laid out a little differently: a line the page sets in pieces is laid out afresh as one).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Difference {
|
||||
any: f64,
|
||||
art: f64,
|
||||
solid: f64,
|
||||
}
|
||||
|
||||
@@ -866,19 +867,20 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
|
||||
/// and what the comparison looks for is art that is missing or out of place.
|
||||
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference {
|
||||
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
|
||||
return Difference { any: 1.0, solid: 1.0 };
|
||||
return Difference { any: 1.0, art: 1.0, solid: 1.0 };
|
||||
}
|
||||
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
|
||||
let (w, h) = (x.width as usize, x.height as usize);
|
||||
let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false);
|
||||
let typed = in_areas(areas, w, h);
|
||||
let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count();
|
||||
// A pixel on the region's edge has neighbours outside it, which don't count as differing.
|
||||
let solid = (1..h.saturating_sub(1))
|
||||
.flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j)))
|
||||
.filter(|&(i, j)| !typed.get(j * w + i).copied().unwrap_or(true) && (j - 1..=j + 1).all(|n| (i - 1..=i + 1).all(|m| at(m, n))))
|
||||
.count();
|
||||
let area = (w as f64 * h as f64).max(1.0);
|
||||
Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, solid: solid as f64 / area }
|
||||
Difference { any: differs.iter().filter(|d| **d).count() as f64 / area, art: art as f64 / area, solid: solid as f64 / area }
|
||||
}
|
||||
|
||||
/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin
|
||||
@@ -1243,7 +1245,9 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
|
||||
const PAST_THE_PAGE: f64 = 1.0;
|
||||
|
||||
/// Do the layers' art and the page's own look alike (where the page holds all the art that prints,
|
||||
/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little.
|
||||
/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little:
|
||||
/// with the share of the pixels that differ away from type when that is notable, otherwise with the
|
||||
/// share of those that differ in the type's areas.
|
||||
fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result<Option<String>, String> {
|
||||
let mut diff = view.difference(layered, frame.rect);
|
||||
if frame.whole
|
||||
@@ -1254,15 +1258,23 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
|
||||
let all = frame.rect.union(art);
|
||||
let (dx, dy) = (frame.page_rect.x0 - frame.rect.x0, frame.page_rect.y0 - frame.rect.y0);
|
||||
let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all);
|
||||
diff = Difference { any: diff.any.max(round.any), solid: diff.solid.max(round.solid) };
|
||||
diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) };
|
||||
}
|
||||
if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE {
|
||||
return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0));
|
||||
}
|
||||
Ok((diff.any > NOTABLE_DIFFERENCE).then(|| {
|
||||
if diff.any <= NOTABLE_DIFFERENCE {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(if diff.art > NOTABLE_DIFFERENCE {
|
||||
format!(
|
||||
"the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)",
|
||||
diff.any * 100.0
|
||||
diff.art * 100.0
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)",
|
||||
(diff.any - diff.art) * 100.0
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -907,6 +907,34 @@ fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("differs from the page's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The page sets "Hi there" in pieces, and the layers lay the line out again as one: the page and
|
||||
/// the layers differ only where the type is.
|
||||
fn page_in_pieces_and_layers(art: &str) -> Vec<u8> {
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show";
|
||||
eps(page, &native_with_text(&layer("Art", true, &format!("{art}{}", text_object(0))), &doc))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_laid_out_again_is_noted_as_type_and_not_as_art_the_layers_dont_describe() {
|
||||
let r = import(&page_in_pieces_and_layers("")).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.starts_with("the type on the layers differs from the file's own page")), "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("brush strokes")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_the_page_doesnt_draw_is_noted_with_its_own_share_and_not_the_types() {
|
||||
// A bar of 0.5 × 80 points that the page doesn't draw, too thin to be refused as an object: 0.4% of
|
||||
// the page, away from the type.
|
||||
let bar = "0 0 0 1 k\n20 10 m\n20.5 10 L\n20.5 90 L\n20 90 L\nf\n";
|
||||
let r = import(&page_in_pieces_and_layers(bar)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
|
||||
let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("differs from the file's own page")).collect();
|
||||
assert_eq!(notes.len(), 1, "{:?}", r.warnings);
|
||||
assert!(notes[0].starts_with("the art on the layers differs from the file's own page in about 0.4% of it (brush strokes"), "{notes:?}");
|
||||
}
|
||||
|
||||
/// An EPS whose page is its art's box `[x0 y0 x1 y1]` (not its artboard, 0 0 100 100), drawing
|
||||
/// `page`, with the editing copy of `layers`.
|
||||
fn eps_of_art(art_box: [u32; 4], page: &str, layers: &str) -> Vec<u8> {
|
||||
|
||||
@@ -80,11 +80,12 @@ const HEX_LINE: usize = 64;
|
||||
|
||||
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
|
||||
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, area type options, type on a path effects, space after paragraphs, tabs) are left out";
|
||||
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs) are left out";
|
||||
pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)";
|
||||
pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)";
|
||||
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
|
||||
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
|
||||
pub const LAYER_NAME: &str = "characters outside Windows Latin-1 in layer names are written as ? (legacy .ai files can't hold them)";
|
||||
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
|
||||
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
|
||||
const FREEFORM: &str = "freeform gradients are written as their average colour";
|
||||
@@ -452,6 +453,25 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- layers and objects ----------
|
||||
|
||||
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
|
||||
/// is written as `?`, with a warning.
|
||||
fn layer_name(&mut self, name: &str) -> String {
|
||||
let mut lost = false;
|
||||
let bytes: Vec<u8> = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
latin::encode(c).unwrap_or_else(|| {
|
||||
lost = true;
|
||||
b'?'
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if lost {
|
||||
self.warn(LAYER_NAME);
|
||||
}
|
||||
ps::bytes(&bytes)
|
||||
}
|
||||
|
||||
/// A top-level layer. False when it isn't a layer.
|
||||
fn layer(&mut self, n: &Node) -> bool {
|
||||
let NodeKind::Layer { color, template, printable, children, clip, preview, dim_images } = &n.kind else { return false };
|
||||
@@ -464,6 +484,7 @@ impl<'a> Writer<'a> {
|
||||
_ => -1,
|
||||
};
|
||||
let flag = |b: bool| u8::from(b);
|
||||
let name = self.layer_name(n.name.as_deref().unwrap_or("Layer"));
|
||||
// A template layer doesn't print and shows dimmed.
|
||||
let _ = writeln!(
|
||||
self.body,
|
||||
@@ -473,7 +494,7 @@ impl<'a> Writer<'a> {
|
||||
flag(!n.locked),
|
||||
flag(*printable && !*template),
|
||||
flag(dim_images.is_some() || *template),
|
||||
ps::string(n.name.as_deref().unwrap_or("Layer")),
|
||||
name,
|
||||
);
|
||||
self.locked = false;
|
||||
self.line("0 A");
|
||||
@@ -501,13 +522,14 @@ impl<'a> Writer<'a> {
|
||||
_ => -1,
|
||||
};
|
||||
let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer"));
|
||||
let name = self.layer_name(&name);
|
||||
let _ = writeln!(
|
||||
self.body,
|
||||
"%AI5_BeginLayer\n0 1 {} {} {} 0 {index} {r} {g} {b} Lb\n{} Ln",
|
||||
u8::from(!n.locked),
|
||||
u8::from(*printable && !*template),
|
||||
u8::from(*template),
|
||||
ps::string(&name),
|
||||
name,
|
||||
);
|
||||
self.locked = false;
|
||||
self.line("0 A");
|
||||
@@ -1279,7 +1301,13 @@ fn writable(t: &TextObject) -> bool {
|
||||
/// Has type `t` attributes a legacy `.ai` text object can't hold (lost when it's written as text)?
|
||||
fn lost_attributes(t: &TextObject) -> bool {
|
||||
let run_lost = |s: &CharStyle| {
|
||||
s.underline || s.strikethrough || s.small_caps.is_some() || s.position != CharPosition::Normal || s.rotation != 0.0 || !s.features.is_empty()
|
||||
s.underline
|
||||
|| s.strikethrough
|
||||
|| s.small_caps.is_some()
|
||||
|| s.position != CharPosition::Normal
|
||||
|| s.rotation != 0.0
|
||||
|| !s.features.is_empty()
|
||||
|| !s.font_axes.is_empty()
|
||||
};
|
||||
let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty();
|
||||
t.runs.iter().any(|r| run_lost(&r.style))
|
||||
|
||||
@@ -516,6 +516,21 @@ fn type_is_written_as_text_objects() {
|
||||
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
/// A legacy `.ai` text object names a font, not a setting of its variation axes: type with
|
||||
/// Variable Font settings is written in the font's style, with the warning that they are left out.
|
||||
#[test]
|
||||
fn variable_font_settings_are_warned_as_left_out() {
|
||||
let mut doc = typed();
|
||||
assert!(!write(&doc).warnings.iter().any(|w| w == TYPE_ATTRIBUTES));
|
||||
let id = texts(&doc)[0].0.id;
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].style.font_axes.insert("wght".into(), 650.0);
|
||||
}
|
||||
let out = write(&doc);
|
||||
assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES), "{:?}", out.warnings);
|
||||
assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_reads_back_as_text() {
|
||||
use vectorcraft_doc::{Justify, TextKind};
|
||||
@@ -627,3 +642,26 @@ fn hidden_objects_can_be_left_out_or_shown() {
|
||||
assert_eq!(pair.len(), 2);
|
||||
assert!(pair.iter().all(|n| n.visible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_names_outside_latin_1_are_replaced_and_warned() {
|
||||
let layered = |name: &str| {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.layers.clear();
|
||||
let layer = doc.add_layer(Some(name));
|
||||
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red(), Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
// A hidden object, so the layer also gets a hidden layer named after it.
|
||||
let mut hidden = shape(&mut doc, shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), red(), Paint::None);
|
||||
hidden.visible = false;
|
||||
doc.insert(Some(layer), usize::MAX, hidden).unwrap();
|
||||
write(&doc)
|
||||
};
|
||||
let out = layered("\u{b808}\u{c774}\u{c5b4} 1 Caf\u{e9}");
|
||||
let text = String::from_utf8_lossy(&out.bytes);
|
||||
assert!(text.contains("(??? 1 Caf\\351) Ln"), "{text}");
|
||||
assert!(text.contains("(??? 1 Caf\\351 \\(hidden\\)) Ln"), "{text}");
|
||||
assert!(out.warnings.iter().any(|w| w == LAYER_NAME), "{:?}", out.warnings);
|
||||
// A name Latin-1 can hold is written as it is, with no warning.
|
||||
assert!(!layered("Caf\u{e9}").warnings.iter().any(|w| w == LAYER_NAME));
|
||||
}
|
||||
@@ -557,8 +557,15 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
|
||||
doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND);
|
||||
let mut depth_notes = vec![];
|
||||
doc.limit_depth_noting(&mut depth_notes);
|
||||
// Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph
|
||||
// spacing, and its tab stops, each within its range (after the depth limit: the walk recurses).
|
||||
doc.keep_type_values_in_range();
|
||||
// Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
|
||||
doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
|
||||
// Untrusted: the superscript, subscript and small caps proportions of the type, the graph
|
||||
// labels and the character styles, within the ranges `document.setup` takes (after the depth
|
||||
// limit: the walk recurses).
|
||||
doc.keep_scripts_in_range();
|
||||
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
|
||||
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
|
||||
}
|
||||
|
||||
@@ -99,6 +99,13 @@ fn window(bytes: &[u8], mut at: usize) -> Option<Rect> {
|
||||
Some(Rect::new(o.x, o.y, o.x + e.x, o.y + e.y).abs())
|
||||
}
|
||||
|
||||
/// Logical → document for a placeable file whose window (`org`, `ext`) is mapped onto its box `b`;
|
||||
/// a negative extent flips that axis, as in GDI.
|
||||
fn window_map(b: Rect, k: f64, org: Point, ext: Point) -> Affine {
|
||||
let ratio = |box_len: f64, e: f64| if e.abs() > 1e-12 { box_len / e } else { 1.0 };
|
||||
Affine::scale_non_uniform(k * ratio(b.width(), ext.x), k * ratio(b.height(), ext.y)) * Affine::translate(-org.to_vec2())
|
||||
}
|
||||
|
||||
pub(super) fn play(bytes: &[u8]) -> Result<Imported, String> {
|
||||
let (start, placeable) = header(bytes).ok_or("the WMF header is damaged")?;
|
||||
let (frame, inch) = match placeable {
|
||||
@@ -109,6 +116,9 @@ pub(super) fn play(bytes: &[u8]) -> Result<Imported, String> {
|
||||
let origin = frame.map_or(Point::ZERO, |f| f.origin());
|
||||
let artboard = frame.map_or(Rect::new(0.0, 0.0, 1.0, 1.0), |f| Rect::new(0.0, 0.0, f.width() * k, f.height() * k));
|
||||
let mut p = Player::new(Kind::Wmf, Affine::scale(k) * Affine::translate(-origin.to_vec2()), 25.4 / inch, artboard);
|
||||
// With a placeable header, the window records map the window onto the box.
|
||||
let placed = placeable.map(|(b, _)| b).filter(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
let mut win = (placed.map_or(Point::ZERO, |b| b.origin()), placed.map_or(Point::ZERO, |b| Point::new(b.width(), b.height())));
|
||||
let mut at = start;
|
||||
let mut ended = false;
|
||||
while let Some(head) = bytes.get(at..at + 6) {
|
||||
@@ -122,7 +132,16 @@ pub(super) fn play(bytes: &[u8]) -> Result<Imported, String> {
|
||||
if !matches!(function, 0x0213 | 0x0214) {
|
||||
p.flush();
|
||||
}
|
||||
if record(&mut p, function, rec.get(6..).unwrap_or_default()).is_none() {
|
||||
if let (Some(b), 0x020B | 0x020C) = (placed, function) {
|
||||
if let Some(v) = point_yx(&mut Reader::new(rec.get(6..).unwrap_or_default())) {
|
||||
if function == 0x020B {
|
||||
win.0 = v;
|
||||
} else {
|
||||
win.1 = v;
|
||||
}
|
||||
p.dev_to_doc = window_map(b, k, win.0, win.1);
|
||||
}
|
||||
} else if record(&mut p, function, rec.get(6..).unwrap_or_default()).is_none() {
|
||||
p.skip();
|
||||
}
|
||||
at += rec.len();
|
||||
|
||||
@@ -289,6 +289,25 @@ fn hand_built_wmf_plays_like_gdi() {
|
||||
assert_eq!(fill_rgb(&p[1]), Some([0, 255, 0]), "the new brush took the freed slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wmf_window_is_mapped_onto_the_placeable_box() {
|
||||
// The box is 1000 × 500 units at 1440 an inch: 50 × 25 pt.
|
||||
let want = Rect::new(12.5, 6.25, 37.5, 18.75);
|
||||
// Window 0,0 – 4000 × 2000: the rectangle 1000,500 – 3000,1500 is the middle half.
|
||||
let ext = vec![(0x020C, le16(&[2000, 4000])), (0x041B, le16(&[1500, 3000, 500, 1000]))];
|
||||
// Window 10000,20000 – 1000 × 500 (the box): the rectangle 10250,20125 – 10750,20375.
|
||||
let org = vec![(0x020B, le16(&[20000, 10000])), (0x020C, le16(&[500, 1000])), (0x041B, le16(&[20375, 10750, 20125, 10250]))];
|
||||
// Window 0,2000 – 4000 × -2000 (y up): the same rectangle, flipped onto the same place.
|
||||
let flip = vec![(0x020B, le16(&[2000, 0])), (0x020C, le16(&[-2000, 4000])), (0x041B, le16(&[1500, 3000, 500, 1000]))];
|
||||
for records in [ext, org, flip] {
|
||||
let back = import(&hand_wmf(1000, 500, &records)).unwrap();
|
||||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0, 25.0), 1e-9));
|
||||
let p = paths(&back.document);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert!(near(p[0].geometric_bounds().unwrap(), want, 1e-6), "{:?}", p[0].geometric_bounds());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_fail_or_import_partly() {
|
||||
let good = emf(&sample()).bytes;
|
||||
|
||||
@@ -951,8 +951,17 @@ fn mean_scale(a: Affine) -> f64 {
|
||||
a.determinant().abs().sqrt()
|
||||
}
|
||||
|
||||
/// Width a PDF line width of 0 imports as, points: a print hairline. In PDF `0 w` is the thinnest
|
||||
/// line the device can draw (ISO 32000-1, 8.4.3.2), not no line.
|
||||
const HAIRLINE: f64 = 0.25;
|
||||
|
||||
/// Document width of the stroke `p` drawn at `scale`: `0 w` becomes a [`HAIRLINE`].
|
||||
fn line_width(p: &StrokeProps, scale: f64) -> f64 {
|
||||
if p.line_width == 0.0 { HAIRLINE } else { p.line_width as f64 * scale }
|
||||
}
|
||||
|
||||
fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(paint, p.line_width as f64 * scale);
|
||||
let mut st = StrokeLayer::new(paint, line_width(p, scale));
|
||||
st.opacity = opacity;
|
||||
st.cap = match p.line_cap {
|
||||
kurbo::Cap::Butt => LineCap::Butt,
|
||||
@@ -1733,7 +1742,7 @@ impl<'p> Builder<'p> {
|
||||
None => named(&info.face),
|
||||
};
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true));
|
||||
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
|
||||
let stroke = stroke.map(|p| (paint.clone(), line_width(p, scale)));
|
||||
let look = Look {
|
||||
font: key,
|
||||
family: info.family,
|
||||
@@ -1850,7 +1859,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
let (paint, opacity) = self.paint(paint, stroke, walked);
|
||||
// A gradient that doesn't extend past its ends paints only between them.
|
||||
let width = match draw_mode {
|
||||
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
|
||||
PathDrawMode::Stroke(p) => line_width(p, mean_scale(transform)),
|
||||
PathDrawMode::Fill(_) => 0.0,
|
||||
};
|
||||
let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width)));
|
||||
|
||||
@@ -644,3 +644,16 @@ fn clips_with_the_page_as_bounding_box_still_clip() {
|
||||
assert!(!clipped("0 0 100 100 re"), "the page");
|
||||
assert!(!clipped("50 -60 m 160 50 l 50 160 l -60 50 l h"), "a turned square around the page");
|
||||
}
|
||||
|
||||
/// Issue #2674: `0 w` is the thinnest line the device can draw (ISO 32000-1, 8.4.3.2), not no
|
||||
/// line: it imports as a visible hairline and survives a PDF round trip.
|
||||
#[test]
|
||||
fn zero_width_lines_import_as_hairlines() {
|
||||
let d = open(&one_page("0 0 0 RG 0 w 10 50 m 90 50 l S 0.9 g 0 w 20 60 30 20 re b 1 w 10 20 m 90 20 l S", "", &[]));
|
||||
let widths = |d: &Document| -> Vec<f64> { all(d).iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect() };
|
||||
let w = widths(&d);
|
||||
assert_eq!(w.len(), 3, "{w:?}");
|
||||
assert!(w.iter().all(|w| *w > 0.0 && *w <= 1.0), "zero width strokes become hairlines: {w:?}");
|
||||
let back = widths(&roundtrip(&d));
|
||||
assert_eq!(back.len(), 3, "hairlines survive the PDF round trip: {back:?}");
|
||||
}
|
||||
@@ -205,6 +205,29 @@ fn variable_font_instances_are_real_text_in_their_own_glyphs() {
|
||||
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
|
||||
}
|
||||
|
||||
/// Type with Variable Font settings is real text in a font embedded at those settings: it draws
|
||||
/// where its outlines would, between the named instances on either side.
|
||||
#[test]
|
||||
fn variable_font_settings_are_real_text_in_their_own_glyphs() {
|
||||
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, VARIABLE_CHARS, two_axis_font};
|
||||
FontDb::global().add_font(two_axis_font().unwrap());
|
||||
let mut widths = vec![];
|
||||
for (s, wght) in [("Regular", None), ("Regular", Some(650.0)), ("Bold", None)] {
|
||||
let font_axes = wght.map(|w| [("wght".to_string(), w)].into_iter().collect()).unwrap_or_default();
|
||||
let st = CharStyle { font_family: TWO_AXIS_FAMILY.into(), font_style: s.into(), font_axes, ..style(48.0) };
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
||||
let r = pdf(&d, false);
|
||||
assert!(r.warnings.is_empty(), "{s} {wght:?}: {:?}", r.warnings);
|
||||
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s} {wght:?}: real text");
|
||||
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
||||
let off =
|
||||
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
||||
assert!(off < 0.5, "{s} {wght:?}: {real:?} vs {outlined:?}");
|
||||
widths.push(real.width());
|
||||
}
|
||||
assert!(widths[0] * 1.1 < widths[1] && widths[1] < widths[2], "{widths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_on_real_text_stay_where_they_were() {
|
||||
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
|
||||
@@ -31,6 +31,8 @@ vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
# A variable font built in code (no font file is committed).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
|
||||
# Live-effect plug-ins the cache tests install, written in WebAssembly text.
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
|
||||
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user