mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-18 05:07:02 -04:00
Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904f0fe522 | ||
|
|
5e6810655f | ||
|
|
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 | ||
|
|
82e363c73b | ||
|
|
b05043c759 | ||
|
|
ef5084f48b | ||
|
|
1ddfb56f66 | ||
|
|
b46ffd0059 |
No files matched your search
Generated
+38
-28
@@ -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"
|
||||
@@ -4507,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -4542,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4553,7 +4558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4565,7 +4570,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4581,7 +4586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -4603,7 +4608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4615,8 +4620,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"hayro-ccitt",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4632,7 +4638,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4648,14 +4654,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"roxmltree 0.21.1",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4684,7 +4692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4707,7 +4715,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4722,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4734,7 +4742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4753,7 +4761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4767,7 +4775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4779,7 +4787,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-pdf",
|
||||
@@ -4809,7 +4817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4823,7 +4831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4850,15 +4858,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4881,8 +4890,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4898,7 +4908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -4918,7 +4928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -4926,7 +4936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4942,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4954,7 +4964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -4988,7 +4998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6098,7 +6108,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -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
|
||||
@@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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. |
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -179,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 {
|
||||
@@ -215,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 {
|
||||
@@ -273,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
|
||||
@@ -333,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).
|
||||
@@ -458,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
|
||||
@@ -499,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 {
|
||||
@@ -525,6 +635,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
@@ -545,6 +656,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +680,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
..
|
||||
} = f;
|
||||
Self {
|
||||
@@ -587,6 +700,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
leading_model,
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1361,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)]
|
||||
|
||||
@@ -37,6 +37,7 @@ mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod lighting;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
@@ -203,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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -197,7 +197,7 @@ impl Rasterize {
|
||||
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) {
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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), 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\"), 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
|
||||
),
|
||||
@@ -278,6 +278,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"digits",
|
||||
"fontAxes",
|
||||
"burasagari",
|
||||
"romanHangingPunctuation",
|
||||
"composer",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
@@ -444,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")];
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
@@ -177,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);
|
||||
}
|
||||
@@ -431,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();
|
||||
@@ -551,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)
|
||||
}
|
||||
|
||||
@@ -687,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.
|
||||
|
||||
@@ -137,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);
|
||||
@@ -145,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);
|
||||
}
|
||||
}
|
||||
@@ -160,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 };
|
||||
@@ -280,7 +321,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => d.text_threads.push(vec![from, to]),
|
||||
}
|
||||
// `to` is new or changed, so [`reflow`] flows the story into it after this edit.
|
||||
// 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)
|
||||
})?;
|
||||
@@ -550,6 +593,17 @@ mod tests {
|
||||
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();
|
||||
@@ -595,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,
|
||||
@@ -1061,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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1212,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)}))?;
|
||||
@@ -1366,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> {
|
||||
|
||||
@@ -85,6 +85,7 @@ pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, h
|
||||
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");
|
||||
|
||||
@@ -642,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;
|
||||
|
||||
+401
-61
@@ -28,6 +28,11 @@
|
||||
//! cached shadows, glows and filtered content at its new place instead of running the effects
|
||||
//! again ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
|
||||
//!
|
||||
//! While [`crate::RenderOptions::dragging`] is set, the renderer draws filtered content that runs
|
||||
//! its effects within [`DRAG_PIXEL_BUDGET`] pixels, at a lower resolution where it would need more,
|
||||
//! and keeps no raster made that way. On the multithreaded pipeline it does the same for shadows
|
||||
//! and glows. The frame after the drag renders them as a still frame does.
|
||||
//!
|
||||
//! A cached raster is made again when something its content reads from outside the object
|
||||
//! changes ([`Inputs`]): the pattern definitions, symbols, images and brushes it uses, the
|
||||
//! document's raster resolution, color mode and substitution highlights, the knockout its object
|
||||
@@ -641,6 +646,19 @@ fn linear_key(view: Affine) -> [u64; 4] {
|
||||
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
|
||||
}
|
||||
|
||||
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
|
||||
/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster
|
||||
/// kept from an earlier frame instead (#2558).
|
||||
const DRAG_PIXEL_BUDGET: f64 = 1.0e6;
|
||||
|
||||
/// The scale (raster pixels a device pixel) for content of `rw` × `rh` device pixels that would
|
||||
/// render at scale `k`, when a drag lowers it to stay within [`DRAG_PIXEL_BUDGET`]; `None` when it
|
||||
/// does not (`dragging` off, or content within the budget at `k`).
|
||||
fn drag_scale(dragging: bool, (rw, rh): (f64, f64), k: f64) -> Option<f64> {
|
||||
let d = (DRAG_PIXEL_BUDGET / (rw * rh)).sqrt();
|
||||
(dragging && d < k).then_some(d)
|
||||
}
|
||||
|
||||
/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content
|
||||
/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height,
|
||||
/// scale). It covers what of the reach can show in the view, the blur's spread around it
|
||||
@@ -648,6 +666,11 @@ fn linear_key(view: Affine) -> [u64; 4] {
|
||||
/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a
|
||||
/// side, whatever the size of the view (#1706). `None`: nothing to draw.
|
||||
fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> {
|
||||
filter_region_within(reach, screen, blur, PIXEL_BUDGET)
|
||||
}
|
||||
|
||||
/// The region [`filter_region`] returns, within `budget` pixels instead of [`PIXEL_BUDGET`].
|
||||
fn filter_region_within(reach: Rect, screen: (u16, u16), blur: f64, budget: f64) -> Option<(f64, f64, u16, u16, f64)> {
|
||||
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
|
||||
let spread = blur.max(0.0) * 1.5 + 2.0;
|
||||
let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread);
|
||||
@@ -659,7 +682,7 @@ fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64
|
||||
return None;
|
||||
}
|
||||
let side = f64::from(u16::MAX);
|
||||
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let k = (budget / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
|
||||
let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side));
|
||||
Some((x0, y0, w as u16, h as u16, k))
|
||||
}
|
||||
@@ -705,6 +728,8 @@ pub(crate) struct ShadowEntry {
|
||||
/// Pixel offset of the raster's top-left from the anchor's view position.
|
||||
rel: (f64, f64),
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
/// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget.
|
||||
scale: f64,
|
||||
made: Made,
|
||||
/// What the silhouette read when the raster was made.
|
||||
inputs: Inputs,
|
||||
@@ -714,6 +739,16 @@ pub(crate) struct ShadowEntry {
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
/// A shadow or glow raster ready to draw ([`Renderer::draw_shadow`]).
|
||||
struct Shadow {
|
||||
image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
/// Raster to view pixels.
|
||||
at: Affine,
|
||||
mode: vectorcraft_doc::color::BlendMode,
|
||||
/// Made before its object moved ([`ShadowEntry::moved`]).
|
||||
moved: bool,
|
||||
}
|
||||
|
||||
impl ShadowEntry {
|
||||
/// The bytes it holds, for [`FX_RASTER_BUDGET`].
|
||||
pub(crate) fn bytes(&self) -> usize {
|
||||
@@ -971,16 +1006,17 @@ pub(crate) struct FxGeom {
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
|
||||
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
|
||||
/// while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are unchanged
|
||||
/// (pans only move it), and while interacting after the object only moved
|
||||
/// ([`Self::move_shadow`]). Returns false when the effect isn't a shadow/glow or the raster
|
||||
/// would be too large (caller falls back).
|
||||
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the
|
||||
/// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU,
|
||||
/// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are
|
||||
/// unchanged (pans only move it), and while interacting after the object only moved
|
||||
/// ([`Self::move_shadow`]). `None` when the effect isn't a shadow/glow or the raster would be
|
||||
/// too large (caller falls back to [`Self::draw_shadow_filter`]).
|
||||
/// While dragging on a multithreaded context, a raster that has to be made again counts as too
|
||||
/// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_shadow_cached(
|
||||
fn shadow_raster(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
n: &Node,
|
||||
i: usize,
|
||||
@@ -988,11 +1024,11 @@ impl Renderer {
|
||||
reach: Rect,
|
||||
owner: Option<&Arc<Node>>,
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) -> bool {
|
||||
) -> Option<Shadow> {
|
||||
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
|
||||
_ => return false,
|
||||
_ => return None,
|
||||
};
|
||||
let linear = linear_key(f.view);
|
||||
let key = (n as *const Node as usize, i, f.ink);
|
||||
@@ -1006,12 +1042,12 @@ impl Renderer {
|
||||
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
|
||||
}
|
||||
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
|
||||
let (image, x, y) = match hit {
|
||||
let (image, x, y, scale, moved) = match hit {
|
||||
Some(e) => {
|
||||
e.stamp = stamp;
|
||||
self.stats.moved += usize::from(e.moved);
|
||||
let p = f.view * e.anchor;
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round())
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved)
|
||||
}
|
||||
None => {
|
||||
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
|
||||
@@ -1019,14 +1055,27 @@ impl Renderer {
|
||||
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
|
||||
let (x0, y0) = (r.x0.floor(), r.y0.floor());
|
||||
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
|
||||
// A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter
|
||||
// layer the caller falls back to can't make its silhouette solid.
|
||||
let scale = if matches!(fx, RasterFx::OuterGlow { .. }) { (w * h / 4.0e6).sqrt().ceil().max(1.0) } else { 1.0 };
|
||||
if scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
|
||||
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
// During a drag, a larger shadow on a multithreaded context is filtered within the
|
||||
// drag's budget (`with_filters`), and no raster is kept.
|
||||
if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (w as u16, h as u16);
|
||||
// Silhouette (the object's painted alpha, and its colors under Darkness), offset by
|
||||
// the shadow distance.
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
let crop = Frame { mt: false, view: Affine::scale(1.0 / scale) * Affine::translate((-x0, -y0)) * f.view, ..*f };
|
||||
let before = self.stats.moved;
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
// A silhouette drawn over moved rasters makes a moved shadow.
|
||||
@@ -1038,7 +1087,7 @@ impl Renderer {
|
||||
let made =
|
||||
Made { pixels: silhouette(&pm, darkness.is_some()), fx: vec![fx.clone()], size: reach.size(), channels: None, outline: None };
|
||||
let (wu, hu) = (w as usize, h as usize);
|
||||
let sigma = blur / 2.0 / f.px;
|
||||
let sigma = blur / 2.0 / f.px / scale;
|
||||
if let Some(darkness) = darkness {
|
||||
// The object's own colors, blurred, with the black Darkness adds (premultiplied).
|
||||
let keep = 1.0 - shade(f.ink, darkness);
|
||||
@@ -1058,7 +1107,9 @@ impl Renderer {
|
||||
*px = vello_cpu::color::PremulRgba8 { r: value(r), g: value(g), b: value(b), a: al as u8 };
|
||||
}
|
||||
} else {
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| p.a as f32).collect();
|
||||
// An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow.
|
||||
let solid = matches!(fx, RasterFx::OuterGlow { .. });
|
||||
let mut a: Vec<f32> = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect();
|
||||
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
|
||||
let [cr, cg, cb] = f.ink.rgb(&color);
|
||||
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
|
||||
@@ -1072,23 +1123,71 @@ impl Renderer {
|
||||
let p = f.view * anchor;
|
||||
let node = Drawn::of(n, owner);
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), made, inputs, moved, stamp });
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, made, inputs, moved, stamp });
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
(image, x0, y0)
|
||||
(image, x0, y0, scale, moved)
|
||||
}
|
||||
};
|
||||
// The blend mode applies per draw: a blend layer would be a full-viewport compositing pass
|
||||
// per shadow. The raster sits on whole pixels, so the cheapest sampler is exact.
|
||||
let (w, h) = (image.width() as f64, image.height() as f64);
|
||||
ctx.set_transform(Affine::translate((x, y)));
|
||||
ctx.set_blend_mode(blend_mode(mode));
|
||||
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
|
||||
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved })
|
||||
}
|
||||
|
||||
/// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx`
|
||||
/// draws in. The blend mode applies per draw: a blend layer would be a full-viewport compositing
|
||||
/// pass per shadow. A full-size raster drawn as made sits on whole pixels, so the cheapest
|
||||
/// sampler is exact.
|
||||
fn draw_shadow(&mut self, ctx: &mut RenderContext, s: &Shadow, to: Affine) {
|
||||
let (w, h) = (s.image.width() as f64, s.image.height() as f64);
|
||||
let at = to * s.at;
|
||||
ctx.set_transform(at);
|
||||
ctx.set_blend_mode(blend_mode(s.mode));
|
||||
let [a, b, c, d, _, _] = at.as_coeffs();
|
||||
let exact = (a, b, c, d) == (1.0, 0.0, 0.0, 1.0) && at.translation().x.fract() == 0.0 && at.translation().y.fract() == 0.0;
|
||||
let quality = if exact { peniko::ImageQuality::Low } else { peniko::ImageQuality::Medium };
|
||||
let sampler = peniko::ImageSampler { quality, ..Default::default() };
|
||||
ctx.reset_paint_transform();
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(s.image.clone()), sampler });
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
|
||||
ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
true
|
||||
}
|
||||
|
||||
/// Drop shadow / outer glow `fx` of what `paint` draws (bounded by `reach`) through a vello
|
||||
/// filter layer, when there is no raster for it ([`Self::shadow_raster`]). vello's shadow filter
|
||||
/// blurs the soft alpha, so an Outer Glow drawn here lacks the raster's solid silhouette.
|
||||
fn draw_shadow_filter(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
f: &Frame,
|
||||
fx: &RasterFx,
|
||||
reach: Rect,
|
||||
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
|
||||
) {
|
||||
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
|
||||
_ => return,
|
||||
};
|
||||
let filter = match darkness {
|
||||
Some(_) => shade_filter(blur),
|
||||
None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)),
|
||||
};
|
||||
// The offset moves the content rather than the filter: vello_cpu drops layer content
|
||||
// that lies entirely outside the viewport before filtering.
|
||||
let reach = reach + Vec2::new(dx, dy);
|
||||
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
|
||||
paint(r, c, &shifted(fr, dx, dy));
|
||||
// Darkness: black over the object's own colors, keeping their alpha.
|
||||
if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) {
|
||||
c.set_transform(fr.view);
|
||||
c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop));
|
||||
c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black]));
|
||||
c.fill_rect(&reach);
|
||||
c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
|
||||
}
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
|
||||
/// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier
|
||||
@@ -1124,7 +1223,8 @@ impl Renderer {
|
||||
&& e.inputs == *inputs
|
||||
&& e.made.fits(&[fx], size, None, None)
|
||||
};
|
||||
let Some((old, rel, w, h)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
|
||||
let Some((old, rel, w, h, scale)) =
|
||||
self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height(), e.scale))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1132,7 +1232,7 @@ impl Renderer {
|
||||
let anchor = reach.origin();
|
||||
let p = f.view * anchor;
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let crop = Frame { mt: false, view: Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f };
|
||||
let crop = Frame { mt: false, view: Affine::scale(1.0 / scale) * Affine::translate((-(p.x + rel.0), -(p.y + rel.1))) * f.view, ..*f };
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
off.flush();
|
||||
let mut pm = vello_cpu::Pixmap::new(w, h);
|
||||
@@ -1453,39 +1553,43 @@ impl Renderer {
|
||||
let reach = content.reach;
|
||||
// The `Arc` drawn: the caller's, or the node `draw_arc` is drawing.
|
||||
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
|
||||
// Below the content: shadows and outer glows.
|
||||
// Below the content: shadows and outer glows, each of the content with the ones before it in the stack, as AI
|
||||
// files draw a glow round a glow and the SVG export nests them. Their rasters are made first to last, each once
|
||||
// and drawn into the later ones' silhouettes as an image (so n of them cost n rasters, not 2^n draws), then
|
||||
// drawn with the last lowest. One without a raster (an object drawn uncached, a glow past the budget) is drawn
|
||||
// through a filter layer from the content alone, and goes into the later silhouettes that way.
|
||||
let mut made: Vec<(&RasterFx, Option<Shadow>)> = vec![];
|
||||
let mut stacked = reach;
|
||||
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
|
||||
if let Some(slot) = content.cache
|
||||
&& self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, owner.as_ref(), paint)
|
||||
{
|
||||
continue;
|
||||
let shadow = {
|
||||
let earlier = &made;
|
||||
let view = f.view;
|
||||
let mut art = |r: &mut Self, c: &mut RenderContext, fr: &Frame| {
|
||||
let to = fr.view * view.inverse();
|
||||
for (fx, s) in earlier.iter().rev() {
|
||||
match s {
|
||||
Some(s) => {
|
||||
r.stats.moved += usize::from(s.moved);
|
||||
r.draw_shadow(c, s, to);
|
||||
}
|
||||
None => r.draw_shadow_filter(c, fr, fx, reach, paint),
|
||||
}
|
||||
}
|
||||
paint(r, c, fr);
|
||||
};
|
||||
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), &mut art))
|
||||
};
|
||||
stacked = match fx {
|
||||
RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)),
|
||||
_ => stacked.inflate(fx.outset(stacked), fx.outset(stacked)),
|
||||
};
|
||||
made.push((fx, shadow));
|
||||
}
|
||||
for (fx, s) in made.iter().rev() {
|
||||
match s {
|
||||
Some(s) => self.draw_shadow(ctx, s, Affine::IDENTITY),
|
||||
None => self.draw_shadow_filter(ctx, f, fx, reach, paint),
|
||||
}
|
||||
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
|
||||
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color, darkness } => (*mode, *opacity, *dx, *dy, *blur, *color, *darkness),
|
||||
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color, None),
|
||||
_ => continue,
|
||||
};
|
||||
let filter = match darkness {
|
||||
Some(_) => shade_filter(blur),
|
||||
None => shadow_filter(0.0, 0.0, blur, pcolor(f.ink, &color)),
|
||||
};
|
||||
// The offset moves the content rather than the filter: vello_cpu drops layer content
|
||||
// that lies entirely outside the viewport before filtering.
|
||||
let reach = reach + Vec2::new(dx, dy);
|
||||
self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| {
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter));
|
||||
paint(r, c, &shifted(fr, dx, dy));
|
||||
// Darkness: black over the object's own colors, keeping their alpha.
|
||||
if let Some(black) = darkness.map(|d| shade(f.ink, d)).filter(|d| *d > 0.0) {
|
||||
c.set_transform(fr.view);
|
||||
c.set_blend_mode(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcAtop));
|
||||
c.set_paint(peniko::Color::new([0.0, 0.0, 0.0, black]));
|
||||
c.fill_rect(&reach);
|
||||
c.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
|
||||
}
|
||||
c.pop_layer();
|
||||
});
|
||||
}
|
||||
// The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur
|
||||
// works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel
|
||||
@@ -1544,6 +1648,8 @@ impl Renderer {
|
||||
/// resolution. A whole raster at full resolution is cached while the object, the
|
||||
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
|
||||
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
|
||||
/// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`]
|
||||
/// pixels and is not cached.
|
||||
fn pixel_content(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
@@ -1628,6 +1734,16 @@ impl Renderer {
|
||||
draw_scaled(ctx, image, at, k);
|
||||
return;
|
||||
}
|
||||
// During a drag, content without a raster to draw renders within the drag's budget and keeps
|
||||
// no raster (#2558).
|
||||
let (k, w, h, key) = match drag_scale(f.opts.dragging, (rw, rh), k) {
|
||||
Some(d) => {
|
||||
self.stats.moved += 1;
|
||||
self.stats.previewed += 1;
|
||||
(d, (rw * d).ceil().clamp(1.0, side), (rh * d).ceil().clamp(1.0, side), None)
|
||||
}
|
||||
None => (k, w, h, key),
|
||||
};
|
||||
let (wu, hu) = (w as u16, h as u16);
|
||||
let view = Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view;
|
||||
let to_doc = view.inverse();
|
||||
@@ -1966,6 +2082,7 @@ impl Renderer {
|
||||
/// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context
|
||||
/// the layer is drawn into an offscreen single-threaded context covering `reach` (document
|
||||
/// space) plus the blur's spread, then composited back with `composite`.
|
||||
/// While dragging, that context holds at most [`DRAG_PIXEL_BUDGET`] pixels.
|
||||
fn with_filters(
|
||||
&mut self,
|
||||
ctx: &mut RenderContext,
|
||||
@@ -1979,6 +2096,16 @@ impl Renderer {
|
||||
return self.inside_layer(|r| draw(r, ctx, f, composite));
|
||||
}
|
||||
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
|
||||
// During a drag, the region holds at most `DRAG_PIXEL_BUDGET` pixels (#2558).
|
||||
let drag = || filter_region_within(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px, DRAG_PIXEL_BUDGET);
|
||||
let (x0, y0, w, h, k) = match f.opts.dragging.then(drag).flatten().filter(|r| r.4 < k) {
|
||||
Some(r) => {
|
||||
self.stats.moved += 1;
|
||||
self.stats.previewed += 1;
|
||||
r
|
||||
}
|
||||
None => (x0, y0, w, h, k),
|
||||
};
|
||||
let mut off = f.offscreen_context(w, h);
|
||||
let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f };
|
||||
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
|
||||
@@ -2100,7 +2227,7 @@ mod tests {
|
||||
use vectorcraft_doc::{Appearance, Document, Effect, Node};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::{Drawn, PIXEL_BUDGET, filter_region};
|
||||
use super::{DRAG_PIXEL_BUDGET, Drawn, PIXEL_BUDGET, drag_scale, filter_region, filter_region_within};
|
||||
use crate::tests_fontchange::fonts_steady;
|
||||
use crate::{RenderOptions, Rendered, Renderer};
|
||||
|
||||
@@ -2123,6 +2250,28 @@ mod tests {
|
||||
assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0));
|
||||
}
|
||||
|
||||
/// #2558: while dragging, content and a shadow's region over the drag's budget at their scale
|
||||
/// render at a lower scale, within the budget; otherwise their scale stays as it is.
|
||||
#[test]
|
||||
fn a_drag_lowers_the_scale_of_rasters_over_its_budget() {
|
||||
let d = drag_scale(true, (2560.0, 1440.0), 1.0).unwrap();
|
||||
assert!(d < 1.0 && (2560.0 * d) * (1440.0 * d) <= DRAG_PIXEL_BUDGET * (1.0 + 1e-9), "{d}");
|
||||
assert_eq!(drag_scale(false, (2560.0, 1440.0), 1.0), None, "not dragging");
|
||||
assert_eq!(drag_scale(true, (1000.0, 800.0), 1.0), None, "within the budget");
|
||||
// At a scale already lowered to 0.5, 1280 × 720 raster pixels: within the budget.
|
||||
assert_eq!(drag_scale(true, (2560.0, 1440.0), 0.5), None);
|
||||
// A 2000 × 1500 pixel square blurred by 20 pixels in a 1600 × 1000 view.
|
||||
let reach = Rect::new(-200.0, -200.0, 1800.0, 1300.0);
|
||||
let (x0, y0, w, h, k) = filter_region_within(reach, (1600, 1000), 20.0, DRAG_PIXEL_BUDGET).unwrap();
|
||||
assert!(f64::from(w) * f64::from(h) <= DRAG_PIXEL_BUDGET && k < 1.0, "{w} × {h} at {k}");
|
||||
// It covers the same pixels as the full-size region, at that scale.
|
||||
let full = filter_region(reach, (1600, 1000), 20.0).unwrap();
|
||||
assert_eq!((x0, y0, full.4), (full.0, full.1, 1.0));
|
||||
assert!((f64::from(w) / k - f64::from(full.2)).abs() <= 1.0 / k && (f64::from(h) / k - f64::from(full.3)).abs() <= 1.0 / k);
|
||||
let small = Rect::new(10.0, 20.0, 110.0, 70.0);
|
||||
assert_eq!(filter_region_within(small, (1600, 1000), 4.0, DRAG_PIXEL_BUDGET), filter_region(small, (1600, 1000), 4.0));
|
||||
}
|
||||
|
||||
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let id = d.alloc_id();
|
||||
@@ -2417,6 +2566,86 @@ mod tests {
|
||||
assert_eq!(lum(feather.pixel(26, 50)), WHITE, "feather stays inside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_glow_blurs_a_solid_silhouette_of_faint_art() {
|
||||
// GUARDS: the glow's strength comes from where the art is, not how opaque it is (a glow round a glow relies on it).
|
||||
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
|
||||
let glow = |fill_opacity: f32| {
|
||||
let mut d =
|
||||
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity;
|
||||
render(&d).pixel(29, 50)
|
||||
};
|
||||
let (faint, opaque) = (glow(0.2), glow(1.0));
|
||||
assert_eq!(faint[1], opaque[1], "faint {faint:?}, opaque {opaque:?}");
|
||||
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glow_glows_round_the_glows_before_it_in_the_stack() {
|
||||
// GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it.
|
||||
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
|
||||
let d = doc_with(
|
||||
r,
|
||||
Color::BLACK,
|
||||
vec![
|
||||
("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 8})),
|
||||
("stylize.outerGlow", json!({"color": "#0000ff", "mode": "normal", "opacity": 100, "blur": 2})),
|
||||
],
|
||||
);
|
||||
let p = render(&d).pixel(22, 50);
|
||||
assert!(p[2] > p[0] + 100, "8 pt out, the tight blue glow round the red one shows: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glow_too_large_for_a_full_size_raster_keeps_its_solid_silhouette() {
|
||||
// GUARDS: at 40× the glow's raster passes the 4 Mpx budget and is made coarser instead of falling back to the
|
||||
// filter layer, which blurs the faint alpha.
|
||||
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let view = Affine::translate((-1100.0, -1900.0)) * Affine::scale(40.0);
|
||||
let glow = |fill_opacity: f32| {
|
||||
let mut d =
|
||||
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().appearance.fill_mut().unwrap().opacity = fill_opacity;
|
||||
Renderer::new().render(&d, 200, 200, view, &opts).pixel(60, 100)
|
||||
};
|
||||
let (faint, opaque) = (glow(0.2), glow(1.0));
|
||||
assert!(faint[1].abs_diff(opaque[1]) <= 2, "faint {faint:?}, opaque {opaque:?}");
|
||||
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thirty_stacked_glows_draw_quickly_cached_or_not() {
|
||||
// GUARDS: each glow is of the content with the glows before it; drawing those again for every glow costs 2^n.
|
||||
// A shape draws through the shadow cache, a symbol instance without it (the filter layers).
|
||||
let glows: Vec<(&str, serde_json::Value)> =
|
||||
(0..30).map(|k| ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 20, "blur": 1 + k % 4}))).collect();
|
||||
let shape = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, glows.clone());
|
||||
let mut instanced = Document::new(100.0, 100.0);
|
||||
let art = Node::path(
|
||||
instanced.alloc_id(),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
instanced.symbols.push(vectorcraft_doc::Symbol { name: "Square".into(), art: std::sync::Arc::new(art) });
|
||||
let mut instance = Node::new(
|
||||
instanced.alloc_id(),
|
||||
vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((30.0, 30.0)) },
|
||||
);
|
||||
instance.appearance.effects = glows.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect();
|
||||
let l = instanced.layers[0].id;
|
||||
instanced.insert(Some(l), 0, instance).unwrap();
|
||||
for d in [&shape, &instanced] {
|
||||
let start = std::time::Instant::now();
|
||||
let p = render(d).pixel(25, 50);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20), "{:?}", start.elapsed());
|
||||
assert!(p[1] < 250, "the glows show: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_effects_are_ignored_and_outline_mode_uses_effected_geometry() {
|
||||
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
|
||||
@@ -2566,6 +2795,56 @@ mod tests {
|
||||
assert!(after != last, "6.3 is not on a whole pixel");
|
||||
}
|
||||
|
||||
/// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a
|
||||
/// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a
|
||||
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so no raster is
|
||||
/// kept for either, and a 1600 × 1000 view near its corner needs more than the drag's budget. The
|
||||
/// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame
|
||||
/// rendered while interacting without a drag (a dialog's preview).
|
||||
#[test]
|
||||
fn a_drag_renders_raster_effects_within_the_drag_budget() {
|
||||
let effects = vec![("stylize.dropShadow", json!({"x": 4, "y": 3, "blur": 7, "opacity": 80})), ("texture.texturizer", json!({}))];
|
||||
let base = doc_with(Rect::new(20.0, 20.0, 80.0, 76.0), Color::rgb(0.9, 0.4, 0.1), effects);
|
||||
let id = base.layers[0].children().unwrap()[0].id;
|
||||
let mut d = base.clone();
|
||||
d.node_mut(id).unwrap().transform(Affine::translate((0.3, 0.2)), false);
|
||||
// The view is centered 10 pt inside the rectangle's right and bottom edges, and shows the
|
||||
// shadow beyond the right edge.
|
||||
let view = Affine::translate((800.0, 500.0)) * Affine::scale(64.0) * Affine::translate((-70.0, -66.0));
|
||||
let still = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let live = RenderOptions { interacting: true, ..still.clone() };
|
||||
let drag = RenderOptions { dragging: true, ..live.clone() };
|
||||
let fresh = Renderer::new().render(&d, 1600, 1000, view, &still).pixels;
|
||||
let mut r = Renderer::new();
|
||||
let _ = r.render(&base, 1600, 1000, view, &still);
|
||||
assert_eq!(r.stats.previewed, 0);
|
||||
let dragged = r.render(&d, 1600, 1000, view, &drag).pixels;
|
||||
assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content");
|
||||
assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again");
|
||||
assert!(r.pixel_fx.is_empty() && r.shadows.is_empty(), "no raster kept");
|
||||
assert!(dragged != fresh, "at the drag's lower resolution");
|
||||
let after = r.render(&d, 1600, 1000, view, &still).pixels;
|
||||
assert_eq!((r.stats.previewed, r.stats.moved), (0, 0));
|
||||
assert!(after == fresh, "the frame after the drag");
|
||||
assert!(r.render(&d, 1600, 1000, view, &live).pixels == fresh, "interacting without a drag");
|
||||
assert_eq!(r.stats.previewed, 0);
|
||||
// A drop shadow of about 2 million pixels at 4000%, within the shadow cache's limit: scaled
|
||||
// by a drag, it can't take its raster along, and renders within the drag's budget.
|
||||
let shadowed = doc_with(Rect::new(20.0, 20.0, 50.0, 46.0), Color::rgb(0.9, 0.4, 0.1), vec![("stylize.dropShadow", json!({"blur": 2}))]);
|
||||
let mut scaled = shadowed.clone();
|
||||
let id = scaled.layers[0].children().unwrap()[0].id;
|
||||
scaled.node_mut(id).unwrap().transform(Affine::scale_about(1.05, vectorcraft_geom::Point::new(35.0, 33.0)), false);
|
||||
let view = Affine::translate((800.0, 500.0)) * Affine::scale(40.0) * Affine::translate((-35.0, -33.0));
|
||||
let mut r = Renderer::new();
|
||||
let _ = r.render(&shadowed, 1600, 1000, view, &still);
|
||||
let kept = r.shadows.len();
|
||||
assert_eq!((kept, r.stats.previewed), (1, 0), "a still frame keeps the shadow's raster");
|
||||
let _ = r.render(&scaled, 1600, 1000, view, &drag);
|
||||
assert_eq!((r.shadows.len(), r.stats.previewed), (kept, 1), "the scaled shadow");
|
||||
let after = r.render(&scaled, 1600, 1000, view, &still).pixels;
|
||||
assert!(after == Renderer::new().render(&scaled, 1600, 1000, view, &still).pixels, "the frame after the drag");
|
||||
}
|
||||
|
||||
/// While interacting, a group holding a symbol instance whose art lies outside the 20 × 20
|
||||
/// square around the origin draws its drop shadow from the raster of an earlier frame. The
|
||||
/// shadow is made from the instance's art, which the renderer measures the group's content by.
|
||||
@@ -2785,6 +3064,67 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reproduce #2558's drag of a textured or blurred rectangle when zoomed in, in a 2560 × 1440
|
||||
/// view centered on the rectangle: the first frame renders as before a drag, and the next eight
|
||||
/// render as a drag's frames, once with `interacting` alone and once with `dragging` as well (as
|
||||
/// the canvas renders them). Then the eighth position renders as the canvas renders the frame
|
||||
/// after the drag. The hashes of that frame and of a fresh renderer's frame compare builds.
|
||||
/// Run with `cargo test -p vectorcraft-render zoomed_drag_perf -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
|
||||
fn zoomed_drag_perf() {
|
||||
let sets: [(&str, Vec<(&str, serde_json::Value)>); 3] = [
|
||||
("Texturizer", vec![("texture.texturizer", json!({}))]),
|
||||
("Gaussian Blur 8 pt", vec![("blur.gaussian", json!({"radius": 8}))]),
|
||||
("Drop Shadow and Craquelure", vec![("stylize.dropShadow", json!({"blur": 7})), ("texture.craquelure", json!({}))]),
|
||||
];
|
||||
let hash = |px: &[u8]| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
px.hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
for (name, effects) in sets {
|
||||
for zoom in [1.0, 4.0, 16.0, 64.0] {
|
||||
let base = doc_with(Rect::new(100.0, 100.0, 300.0, 250.0), Color::rgb(0.9, 0.4, 0.1), effects.clone());
|
||||
let id = base.layers[0].children().unwrap()[0].id;
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let view = Affine::translate((1280.0, 720.0)) * Affine::scale(zoom) * Affine::translate((-200.0, -175.0));
|
||||
for dragging in [false, true] {
|
||||
let live = RenderOptions { interacting: true, dragging, ..opts.clone() };
|
||||
let mut r = Renderer::new();
|
||||
r.threads = crate::default_threads();
|
||||
let (mut ms, mut doc) = (vec![], base.clone());
|
||||
for frame in 0..9 {
|
||||
doc = base.clone();
|
||||
let by = (f64::from(frame) * 0.75 / zoom, f64::from(frame) * 0.5 / zoom);
|
||||
doc.node_mut(id).unwrap().transform(Affine::translate(by), false);
|
||||
let start = std::time::Instant::now();
|
||||
std::hint::black_box(r.render(&doc, 2560, 1440, view, if frame > 0 { &live } else { &opts }));
|
||||
if frame > 0 {
|
||||
ms.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
let previewed = r.stats.previewed;
|
||||
let start = std::time::Instant::now();
|
||||
let still = r.render(&doc, 2560, 1440, view, &opts);
|
||||
let after = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let fresh = Renderer::new().render(&doc, 2560, 1440, view, &opts);
|
||||
ms.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"{name} at {:.0}%, dragging {dragging}: drag frames median {:.1} ms (min {:.1}, max {:.1}), {previewed} previewed; the frame after the drag {after:.1} ms; hashes {:016x} {:016x}",
|
||||
zoom * 100.0,
|
||||
(ms[3] + ms[4]) * 0.5,
|
||||
ms[0],
|
||||
ms[7],
|
||||
hash(&still.pixels),
|
||||
hash(&fresh.pixels)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_filters_on_content_larger_than_the_budget() {
|
||||
// Zoomed in 200×: the 40 pt square covers 8000 × 8000 px around a 200 × 200 view.
|
||||
|
||||
@@ -173,6 +173,12 @@ pub struct RenderOptions {
|
||||
/// running the effects again (see `fx`). Off, no raster made that way, or made over one, is
|
||||
/// drawn: the effects run again.
|
||||
pub interacting: bool,
|
||||
/// Screen views during a tool's drag on the canvas: the renderer draws filtered content within
|
||||
/// `fx::DRAG_PIXEL_BUDGET` pixels, at a lower resolution where it would need more, unless it
|
||||
/// can draw the content from a raster kept from an earlier frame. With [`Renderer::threads`]
|
||||
/// above 0 it does the same for drop shadows, outer glows and inner glows. No raster made that
|
||||
/// way is kept (see `fx`). Off, they render as in a still frame.
|
||||
pub dragging: bool,
|
||||
/// Appearance of Black's Rich Black: pure CMYK black (black ink alone at 100%) shows as RGB
|
||||
/// black. Off, it shows through the CMYK profile, a dark grey, as other colours do.
|
||||
pub rich_black: bool,
|
||||
@@ -254,6 +260,7 @@ impl Default for RenderOptions {
|
||||
supersample: false,
|
||||
image_mips: false,
|
||||
interacting: false,
|
||||
dragging: false,
|
||||
rich_black: false,
|
||||
}
|
||||
}
|
||||
@@ -545,8 +552,12 @@ pub struct FrameStats {
|
||||
pub drawn: usize,
|
||||
pub culled: usize,
|
||||
/// The drop shadows, outer glows and filtered contents the frame drew from rasters made before
|
||||
/// their object moved, or made over such rasters ([`RenderOptions::interacting`]).
|
||||
/// their object moved, or made over such rasters ([`RenderOptions::interacting`]), and those it
|
||||
/// drew at a drag's lower resolution ([`Self::previewed`]).
|
||||
pub moved: usize,
|
||||
/// The filtered contents, shadows and glows the frame drew at a drag's lower resolution
|
||||
/// ([`RenderOptions::dragging`]). [`Self::moved`] counts them too.
|
||||
pub previewed: usize,
|
||||
/// How many threads composed the frame from its two ink planes, in a CMYK document that shows
|
||||
/// transparency; 0 for other frames.
|
||||
pub ink_threads: usize,
|
||||
|
||||
@@ -9,6 +9,8 @@ description = "VectorCraft SVG import and export"
|
||||
workspace = true
|
||||
|
||||
[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 }
|
||||
|
||||
@@ -1691,8 +1691,9 @@ impl Writer<'_> {
|
||||
self.num(*dy),
|
||||
flood(color, *opacity)
|
||||
),
|
||||
// The glow blurs a solid silhouette: alpha × 255 makes any alpha full.
|
||||
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
|
||||
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"linear\" slope=\"255\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
|
||||
sd(*blur),
|
||||
flood(color, *opacity)
|
||||
),
|
||||
|
||||
@@ -79,33 +79,25 @@ fn percent_decoded(s: &str) -> String {
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Is `p` an absolute path here or on another system (`/…`, `C:/…`, `C:\…`)?
|
||||
fn is_absolute(p: &str) -> bool {
|
||||
Path::new(p).is_absolute() || p.starts_with('/') || matches!(p.as_bytes(), [d, b':', b'/' | b'\\', ..] if d.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Where href `h` points (a path, made absolute from `folder`, or a URL) and whether it is a
|
||||
/// file that can be read.
|
||||
/// Where href `h` points (a path, or a URL) and whether it is a file that may be read: a local
|
||||
/// file, or one on the network share the SVG is on. A relative path is taken from `folder`, the
|
||||
/// SVG's own; without it, only absolute paths are read (see `craft_docpath`).
|
||||
fn target(h: &str, folder: Option<&str>) -> (String, bool) {
|
||||
let h = percent_decoded(h.trim());
|
||||
let path = match h.strip_prefix("file://") {
|
||||
Some(rest) => {
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
// `file:///C:/art.png` → `C:/art.png`.
|
||||
match rest.as_bytes() {
|
||||
[b'/', d, b':', ..] if d.is_ascii_alphabetic() => rest.get(1..).unwrap_or(rest).to_string(),
|
||||
_ => rest.to_string(),
|
||||
}
|
||||
}
|
||||
None if h.contains("://") => return (h, false),
|
||||
None => h,
|
||||
};
|
||||
if is_absolute(&path) {
|
||||
return (path, true);
|
||||
let h = h.trim();
|
||||
// `craft_docpath` decodes a `file:` URL itself; any other href is decoded here, once.
|
||||
let file_url = h.get(..5).is_some_and(|s| s.eq_ignore_ascii_case("file:"));
|
||||
let stored = if file_url { h.to_string() } else { percent_decoded(h) };
|
||||
if !file_url && stored.contains("://") {
|
||||
return (stored, false);
|
||||
}
|
||||
match folder {
|
||||
Some(f) => (Path::new(f).join(&path).to_string_lossy().into_owned(), true),
|
||||
None => (path, false),
|
||||
// Resolved as a document inside `folder`: only the folder is used.
|
||||
let document = folder.map(|f| Path::new(f).join("-"));
|
||||
match craft_docpath::resolve(&stored, document.as_deref()) {
|
||||
Ok(path) => {
|
||||
let local = path.is_absolute();
|
||||
(path.to_string_lossy().into_owned(), local)
|
||||
}
|
||||
Err(_) => (stored, false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,12 +227,32 @@ mod tests {
|
||||
#[test]
|
||||
fn hrefs_resolve_against_the_folder() {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
assert_eq!(target("photo%20one.png", Some("/art")), (format!("/art{sep}photo one.png"), true));
|
||||
assert_eq!(target("photo.png", None), ("photo.png".into(), false));
|
||||
assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true));
|
||||
assert_eq!(target("file:///C:/art/a.png", None), ("C:/art/a.png".into(), true));
|
||||
assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true));
|
||||
assert_eq!(target("https://example.com/a.png", Some("/art")), ("https://example.com/a.png".into(), false));
|
||||
let art = std::env::temp_dir().join("art");
|
||||
let art = art.to_str().unwrap();
|
||||
assert_eq!(target("photo%20one.png", Some(art)), (format!("{art}{sep}photo one.png"), true));
|
||||
assert!(!target("photo.png", None).1);
|
||||
assert_eq!(target("file:///C:/art/a.png", None), (r"C:\art\a.png".into(), cfg!(windows)));
|
||||
assert_eq!(target("https://example.com/a.png", Some(art)), ("https://example.com/a.png".into(), false));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true));
|
||||
assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hrefs_to_other_computers_are_not_read() {
|
||||
let art = std::env::temp_dir().join("art");
|
||||
for h in [
|
||||
r"\\192.0.2.1\share\x.png",
|
||||
"//192.0.2.1/share/x.png",
|
||||
"file:////192.0.2.1/share/x.png",
|
||||
"file://192.0.2.1/share/x.png",
|
||||
"%5C%5C192.0.2.1%5Cshare%5Cx.png",
|
||||
] {
|
||||
assert!(!target(h, art.to_str()).1, "{h}");
|
||||
assert!(!target(h, None).1, "{h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -229,8 +229,11 @@ fn eval(f: &Filter) -> Option<V> {
|
||||
[Some(r), Some(g), Some(b)] if r == g && g == b && (srgb || r == 0.0 || r == 1.0) => Some(r),
|
||||
_ => None,
|
||||
};
|
||||
// Our Outer Glow's solid silhouette (alpha × 255): an object's silhouette as far as the effects go.
|
||||
let solid = matches!(c.func_a(), TransferFunction::Linear { slope, intercept } if *slope >= 255.0 && *intercept == 0.0);
|
||||
match (input(c.input())?, keep, scale(c.func_a())) {
|
||||
(V::Alpha(s), _, _) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }),
|
||||
(V::Alpha(s), _, _) if identity && solid && s.plain() => V::Alpha(s),
|
||||
(v @ (V::Graphic | V::Blurred(..) | V::Shade { .. }), Some(keep), Some(opacity)) => v.scaled(keep, opacity)?,
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
@@ -315,6 +315,27 @@ fn a_drop_shadow_with_darkness_exports_as_the_canvas_draws_it() {
|
||||
assert_similar(&canvas, &exported, 40.0, 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exported_outer_glow_round_faint_art_draws_as_the_canvas_does() {
|
||||
// GUARDS: the export's glow blurs a solid silhouette like the renderer, not the art's faint alpha.
|
||||
let mut d = Document::new(200.0, 120.0);
|
||||
let mut n = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(70.0, 40.0, 130.0, 80.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.fill_mut().unwrap().opacity = 0.2;
|
||||
n.appearance.effects = vec![Effect {
|
||||
id: "stylize.outerGlow".into(),
|
||||
params: json!({"blur": 12.0, "color": "#ff0000", "opacity": 100.0, "mode": "normal"}),
|
||||
visible: true,
|
||||
}];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 40.0, 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_and_effects_export_import_export_is_stable() {
|
||||
// A doc with a symbol instance carrying a shadow: export → import → export is stable.
|
||||
|
||||
@@ -21,3 +21,5 @@ kurbo = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
# A Group 4 encoder and the T.4 run codes, to write CCITT Group 3 TIFFs (`tiff::group3`).
|
||||
fax = "0.2"
|
||||
@@ -12,7 +12,8 @@
|
||||
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
|
||||
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
|
||||
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
|
||||
//! - [`tiff`]: hand-written TIFF files, chunky or planar, for image import tests.
|
||||
//! - [`tiff`]: hand-written TIFF files, chunky or planar, or compressed with CCITT Group 3, for
|
||||
//! image import tests.
|
||||
//! - [`fonts`]: font files made from the bundled fonts (a renamed family) for font tests.
|
||||
//!
|
||||
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
|
||||
|
||||
+172
-3
@@ -1,6 +1,13 @@
|
||||
//! Hand-written TIFF files for import tests: little-endian, uncompressed, 8 or 16 bits a sample,
|
||||
//! with each pixel's samples stored together (chunky) or one plane per sample (planar,
|
||||
//! PlanarConfiguration 2) (TIFF 6.0, sections 2 and 8).
|
||||
//! PlanarConfiguration 2) (TIFF 6.0, sections 2 and 8); and 1-bit files compressed with CCITT
|
||||
//! Group 3 (ITU-T T.4; Compression 2 and 3, T4Options and FillOrder as libtiff's `tiff.h` defines
|
||||
//! them), coded by [`group3`].
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use fax::{BitWriter, Bits};
|
||||
|
||||
/// One image: its channels in the order the file stores them, color first, extra samples after.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -32,6 +39,16 @@ fn align(out: &mut Vec<u8>) {
|
||||
/// A directory entry: its tag, field type, count and value bytes.
|
||||
type Entry = (u16, u16, usize, Vec<u8>);
|
||||
|
||||
/// An entry of SHORT values.
|
||||
fn shorts(tag: u16, v: &[u16]) -> Entry {
|
||||
(tag, 3, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect())
|
||||
}
|
||||
|
||||
/// An entry of LONG values.
|
||||
fn longs(tag: u16, v: &[u32]) -> Entry {
|
||||
(tag, 4, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect())
|
||||
}
|
||||
|
||||
/// The TIFF file of `t`: the header, the directory at offset 8, the values longer than four
|
||||
/// bytes, then the image in one strip a plane.
|
||||
pub fn tiff(t: &Tiff) -> Vec<u8> {
|
||||
@@ -50,8 +67,6 @@ pub fn tiff(t: &Tiff) -> Vec<u8> {
|
||||
let samples = u16::try_from(t.channels.len()).unwrap();
|
||||
let counts: Vec<u32> = strips.iter().map(|s| u32::try_from(s.len()).unwrap()).collect();
|
||||
let entries = |offsets: &[u32]| -> Vec<Entry> {
|
||||
let shorts = |tag, v: &[u16]| (tag, 3, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect());
|
||||
let longs = |tag, v: &[u32]| (tag, 4, v.len(), v.iter().flat_map(|x| x.to_le_bytes()).collect());
|
||||
let rational = |tag, n: u32| (tag, 5, 1, [n.to_le_bytes(), 1u32.to_le_bytes()].concat());
|
||||
// In ascending tag order.
|
||||
let mut e = vec![
|
||||
@@ -77,6 +92,12 @@ pub fn tiff(t: &Tiff) -> Vec<u8> {
|
||||
}
|
||||
e
|
||||
};
|
||||
file(entries, strips)
|
||||
}
|
||||
|
||||
/// The TIFF file of the directory `entries` gives for the strips' offsets, and `strips`: the
|
||||
/// header, the directory at offset 8, the values longer than four bytes, then the strips.
|
||||
fn file(entries: impl Fn(&[u32]) -> Vec<Entry>, strips: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
// The strips' offsets follow from the sizes of the rest, which don't depend on them.
|
||||
let padded = |n: usize| n + n % 2;
|
||||
let sized = entries(&vec![0; strips.len()]);
|
||||
@@ -114,3 +135,151 @@ pub fn tiff(t: &Tiff) -> Vec<u8> {
|
||||
assert_eq!(out.len(), at, "the strips sit where the directory says");
|
||||
out
|
||||
}
|
||||
|
||||
/// A 1-bit image compressed with CCITT Group 3, in strips of `rows_per_strip` rows or in tiles,
|
||||
/// each coded on its own.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Group3<'a> {
|
||||
/// The pixels, row by row, `true` for a black run's.
|
||||
pub rows: &'a [Vec<bool>],
|
||||
/// Compression: 2 (modified Huffman: each row starts at a byte, no EOL codes) or 3 (T.4: an
|
||||
/// EOL code before each row).
|
||||
pub compression: u16,
|
||||
/// T4Options, written with Compression 3: bit 0 codes each row after a strip's first against
|
||||
/// the row above (two-dimensional), after a tag bit; bit 2 adds fill bits so that each EOL
|
||||
/// code ends at a byte.
|
||||
pub t4_options: u32,
|
||||
/// PhotometricInterpretation: 0 white is zero, 1 black is zero.
|
||||
pub photometric: u16,
|
||||
/// FillOrder: 1 (written as no tag) or 2 (each byte's lowest bit first).
|
||||
pub fill_order: u16,
|
||||
pub rows_per_strip: u32,
|
||||
/// Tiles of this width and height instead of strips (TileWidth, TileLength, TileOffsets and
|
||||
/// TileByteCounts), left to right and top to bottom; their pixels past the image are white.
|
||||
pub tile: Option<(u32, u32)>,
|
||||
/// With Compression 3, the last strip or tile ends with six EOL codes (T.4's return to
|
||||
/// control).
|
||||
pub rtc: bool,
|
||||
}
|
||||
|
||||
/// The bits a fax encoder writes, in order.
|
||||
#[derive(Clone, Default)]
|
||||
struct Written(Rc<RefCell<Vec<Bits>>>);
|
||||
|
||||
impl BitWriter for Written {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn write(&mut self, bits: Bits) -> Result<(), Self::Error> {
|
||||
self.0.borrow_mut().push(bits);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `row` as one-dimensional runs (modified Huffman), white and black in turn from a white one
|
||||
/// (ITU-T T.4 §4.1.1); runs of 64 to 1791 pixels take a make-up code.
|
||||
fn runs(row: &[bool]) -> Vec<Bits> {
|
||||
let mut out = vec![];
|
||||
let (mut x, mut black) = (0, false);
|
||||
while x < row.len() {
|
||||
let n = row[x..].iter().take_while(|b| **b == black).count();
|
||||
let code = |n: usize| if black { fax::maps::black::encode(n as u16) } else { fax::maps::white::encode(n as u16) }.unwrap();
|
||||
if n >= 64 {
|
||||
out.push(code(n & !63));
|
||||
}
|
||||
out.push(code(n % 64));
|
||||
(x, black) = (x + n, !black);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The coded data of the strip or tile of `g` that holds `rows`; the `last` one carries the six
|
||||
/// EOL codes [`Group3::rtc`] asks for.
|
||||
fn group3_strip(g: &Group3, rows: &[Vec<bool>], last: bool) -> Vec<u8> {
|
||||
let two_d = g.compression == 3 && g.t4_options & 1 != 0;
|
||||
let fill = g.compression == 3 && g.t4_options & 4 != 0;
|
||||
let written = Written::default();
|
||||
let mut encoder = fax::encoder::Encoder::new(written.clone());
|
||||
let mut bits: Vec<Bits> = vec![];
|
||||
let used = |bits: &[Bits]| bits.iter().map(|b| usize::from(b.len)).sum::<usize>();
|
||||
let zeros = |n: usize| Bits { data: 0, len: n as u8 };
|
||||
let eol = |bits: &mut Vec<Bits>, tag: Option<bool>| {
|
||||
// Fill bits so that the EOL code (12 bits) ends at a byte.
|
||||
let pad = (8 - (used(bits) + 12) % 8) % 8;
|
||||
if fill && pad > 0 {
|
||||
bits.push(zeros(pad));
|
||||
}
|
||||
bits.push(fax::maps::EOL);
|
||||
if let Some(one_d) = tag {
|
||||
bits.push(Bits { data: u16::from(one_d), len: 1 });
|
||||
}
|
||||
};
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
// The encoder codes each row against the one above (as T.6 and T.4's two-dimensional rows
|
||||
// are coded), the first against a white row.
|
||||
let at = written.0.borrow().len();
|
||||
encoder.encode_line(row.iter().map(|b| if *b { fax::Color::Black } else { fax::Color::White }), row.len() as u16).unwrap();
|
||||
let coded = written.0.borrow()[at..].to_vec();
|
||||
if g.compression == 2 {
|
||||
let pad = (8 - used(&bits) % 8) % 8;
|
||||
if pad > 0 {
|
||||
bits.push(zeros(pad));
|
||||
}
|
||||
} else {
|
||||
eol(&mut bits, two_d.then_some(i == 0));
|
||||
}
|
||||
bits.extend(if two_d && i > 0 { coded } else { runs(row) });
|
||||
}
|
||||
if g.compression == 3 && g.rtc && last {
|
||||
for _ in 0..6 {
|
||||
eol(&mut bits, two_d.then_some(true));
|
||||
}
|
||||
}
|
||||
let mut w = fax::VecWriter::new();
|
||||
for b in bits {
|
||||
w.write(b).unwrap();
|
||||
}
|
||||
let data = w.finish();
|
||||
if g.fill_order == 2 { data.iter().map(|b| b.reverse_bits()).collect() } else { data }
|
||||
}
|
||||
|
||||
/// The TIFF file of `g`, its rows coded by [`group3_strip`].
|
||||
pub fn group3(g: &Group3) -> Vec<u8> {
|
||||
let height = u32::try_from(g.rows.len()).unwrap();
|
||||
let width = u32::try_from(g.rows[0].len()).unwrap();
|
||||
assert!(g.rows.iter().all(|r| r.len() == g.rows[0].len()), "every row has the same width");
|
||||
assert!(matches!(g.compression, 2 | 3), "Compression 2 or 3");
|
||||
let chunks: Vec<Vec<Vec<bool>>> = match g.tile {
|
||||
Some((tw, th)) => {
|
||||
let (across, down) = (width.div_ceil(tw), height.div_ceil(th));
|
||||
let px = |x: u32, y: u32| g.rows.get(y as usize).and_then(|r| r.get(x as usize)).copied().unwrap_or(false);
|
||||
let tile = |tx: u32, ty: u32| (0..th).map(|r| (0..tw).map(|c| px(tx * tw + c, ty * th + r)).collect()).collect();
|
||||
(0..down).flat_map(|ty| (0..across).map(move |tx| (tx, ty))).map(|(tx, ty)| tile(tx, ty)).collect()
|
||||
}
|
||||
None => g.rows.chunks(usize::try_from(g.rows_per_strip).unwrap()).map(<[Vec<bool>]>::to_vec).collect(),
|
||||
};
|
||||
let strips: Vec<Vec<u8>> = chunks.iter().enumerate().map(|(i, rows)| group3_strip(g, rows, i + 1 == chunks.len())).collect();
|
||||
let counts: Vec<u32> = strips.iter().map(|s| u32::try_from(s.len()).unwrap()).collect();
|
||||
let entries = |offsets: &[u32]| -> Vec<Entry> {
|
||||
// In ascending tag order.
|
||||
let mut e =
|
||||
vec![longs(256, &[width]), longs(257, &[height]), shorts(258, &[1]), shorts(259, &[g.compression]), shorts(262, &[g.photometric])];
|
||||
if g.fill_order != 1 {
|
||||
e.push(shorts(266, &[g.fill_order]));
|
||||
}
|
||||
if g.tile.is_none() {
|
||||
e.push(longs(273, offsets));
|
||||
}
|
||||
e.push(shorts(277, &[1]));
|
||||
if g.tile.is_none() {
|
||||
e.extend([longs(278, &[g.rows_per_strip]), longs(279, &counts)]);
|
||||
}
|
||||
if g.compression == 3 {
|
||||
e.push(longs(292, &[g.t4_options]));
|
||||
}
|
||||
if let Some((tw, th)) = g.tile {
|
||||
e.extend([longs(322, &[tw]), longs(323, &[th]), longs(324, offsets), longs(325, &counts)]);
|
||||
}
|
||||
e
|
||||
};
|
||||
file(entries, strips)
|
||||
}
|
||||
+81
-28
@@ -892,11 +892,22 @@ pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
|
||||
let native_family = native_family_name(f, &family);
|
||||
let mut keys = FaceKeys::of(f, &family, &style);
|
||||
let traits = FontTraits::of(f, &keys);
|
||||
let mut instances = named_instances(f, &family, &style, &mut keys, native_family.clone());
|
||||
let (mut instances, renamed) = named_instances(f, &family, &style, &mut keys, native_family.clone());
|
||||
for i in &mut instances {
|
||||
i.traits = traits;
|
||||
}
|
||||
let face = FaceStyle { family, native_family, style, keys, variations: vec![], weight: None, italic: false, traits };
|
||||
// A variable font's default face renders at its `wght` default, whatever its name table and
|
||||
// OS/2 table say (Noto Sans Hebrew: named `Light`, weight class 300, default instance Regular
|
||||
// at 400): weigh it so, and name it by its default instance when that is another weight.
|
||||
let weight = f
|
||||
.axes()
|
||||
.iter()
|
||||
.find(|a| a.tag().to_be_bytes() == *b"wght")
|
||||
.map(|a| a.default_value())
|
||||
.filter(|w| w.is_finite())
|
||||
.map(|w| w.clamp(1.0, 1000.0));
|
||||
let style = renamed.unwrap_or(style);
|
||||
let face = FaceStyle { family, native_family, style, keys, variations: vec![], weight, italic: false, traits };
|
||||
std::iter::once(face).chain(instances).collect()
|
||||
}
|
||||
|
||||
@@ -905,17 +916,40 @@ pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
|
||||
/// names already taken. The default instance's name, when it isn't the face's style name (a face
|
||||
/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give
|
||||
/// fewer instances or none; the counts read are capped.
|
||||
fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mut FaceKeys, native_family: Option<String>) -> Vec<FaceStyle> {
|
||||
fn named_instances(
|
||||
f: &skrifa::FontRef<'_>,
|
||||
family: &str,
|
||||
style: &str,
|
||||
keys: &mut FaceKeys,
|
||||
native_family: Option<String>,
|
||||
) -> (Vec<FaceStyle>, Option<String>) {
|
||||
let axes = f.axes();
|
||||
if axes.is_empty() || axes.len() > MAX_AXES {
|
||||
return vec![];
|
||||
return (vec![], None);
|
||||
}
|
||||
let tags: Vec<Tag> = axes.iter().map(|a| a.tag()).collect();
|
||||
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
|
||||
let is_default = |coords: &[f32]| coords.len() == defaults.len() && coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3);
|
||||
// The face renders as its default instance. When that instance is another weight than the
|
||||
// name table says (`Light` face, Regular default instance), the instance names the face.
|
||||
let renamed = tags.iter().any(|t| t.to_be_bytes() == *b"wght").then(|| {
|
||||
f.named_instances()
|
||||
.iter()
|
||||
.take(MAX_INSTANCES)
|
||||
.filter(|inst| is_default(&inst.user_coords().take(MAX_AXES).collect::<Vec<f32>>()))
|
||||
.find_map(|inst| name(f, &[inst.subfamily_name_id()]))
|
||||
.filter(|d| style_weight(d) != style_weight(style))
|
||||
});
|
||||
let renamed = renamed.flatten();
|
||||
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
|
||||
// Technical Note #5902), as PDF and `.ai` files name them.
|
||||
let prefix = name(f, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| family.to_string());
|
||||
let mut taken = vec![norm(style)];
|
||||
if let Some(new) = &renamed {
|
||||
let old = norm(style);
|
||||
keys.styles.retain(|k| *k != old);
|
||||
keys.styles.push(norm(new));
|
||||
}
|
||||
let mut taken = vec![norm(renamed.as_deref().unwrap_or(style))];
|
||||
let mut out = Vec::new();
|
||||
for inst in f.named_instances().iter().take(MAX_INSTANCES) {
|
||||
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
|
||||
@@ -960,7 +994,7 @@ fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mu
|
||||
traits: FontTraits::default(),
|
||||
});
|
||||
}
|
||||
out
|
||||
(out, renamed)
|
||||
}
|
||||
|
||||
/// Parse every face in `data` (a font file or collection) and the named instances of its variable
|
||||
@@ -1560,6 +1594,13 @@ fn style_italic(style: &str) -> bool {
|
||||
s.contains("italic") || s.contains("oblique") || s == "it"
|
||||
}
|
||||
|
||||
/// How far `face` is from the style asked for: its weight difference, and a penalty for the wrong
|
||||
/// slant. The penalty is less than the weight gap between Regular and Bold (and Bold and Black),
|
||||
/// so Bold Italic asked of a family with Bold and Italic faces only gets the Bold (#2491).
|
||||
fn style_distance(face: &FontFace, weight: f32, italic: bool) -> f32 {
|
||||
(face.weight - weight).abs() + if face.italic != italic { 250.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// [`FontDb::menu_family_list`], with the [`FontDb::family_list`] it was built from.
|
||||
type MenuFamilies = (Arc<[String]>, Arc<[String]>);
|
||||
|
||||
@@ -2207,37 +2248,36 @@ impl FontDb {
|
||||
return Some((*f).clone());
|
||||
}
|
||||
let (tw, ti) = (style_weight(style), style_italic(style));
|
||||
cands
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 };
|
||||
let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 };
|
||||
sa.total_cmp(&sb)
|
||||
})
|
||||
.map(|f| (*f).clone())
|
||||
cands.iter().min_by(|a, b| style_distance(a, tw, ti).total_cmp(&style_distance(b, tw, ti))).map(|f| (*f).clone())
|
||||
}
|
||||
|
||||
/// First face (fallback family first, then load order) that covers `c`; on native, system
|
||||
/// fonts are loaded lazily the first time no loaded face covers a character.
|
||||
/// [`fallback_for_style`](Self::fallback_for_style) for upright Regular text.
|
||||
pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
self.fallback_for_style(c, exclude, 400.0, false)
|
||||
}
|
||||
|
||||
/// First family (fallback family first, then load order) with a face that covers `c`, in that
|
||||
/// family's face nearest `weight` and `italic`; on native, system fonts are loaded lazily the
|
||||
/// first time no loaded face covers a character (#2491).
|
||||
pub(crate) fn fallback_for_style(&self, c: char, exclude: u32, weight: f32, italic: bool) -> Option<Arc<FontFace>> {
|
||||
// Thai (Lao, Khmer, Myanmar) takes a font made for it, when one is installed, rather than
|
||||
// whichever face covering it happened to load first (a display face, say).
|
||||
let prefer = script_families(c);
|
||||
if let Some(f) = self.family_fallback(c, exclude, prefer) {
|
||||
if let Some(f) = self.family_fallback(c, exclude, prefer, weight, italic) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.load_families(c, prefer)
|
||||
&& let Some(f) = self.family_fallback(c, exclude, prefer)
|
||||
&& let Some(f) = self.family_fallback(c, exclude, prefer, weight, italic)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
if let Some(f) = self.loaded_fallback(c, exclude) {
|
||||
if let Some(f) = self.loaded_fallback(c, exclude, weight, italic) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_fallback(c) {
|
||||
return self.loaded_fallback(c, exclude);
|
||||
return self.loaded_fallback(c, exclude, weight, italic);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -2249,14 +2289,14 @@ impl FontDb {
|
||||
self.fallback_for(c, 0)
|
||||
}
|
||||
|
||||
/// The loaded face of the first of `families` that covers `c` (upright, nearest Regular).
|
||||
fn family_fallback(&self, c: char, exclude: u32, families: &[&str]) -> Option<Arc<FontFace>> {
|
||||
/// The loaded face of the first of `families` that covers `c`, nearest `weight` and `italic`.
|
||||
fn family_fallback(&self, c: char, exclude: u32, families: &[&str], weight: f32, italic: bool) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
families.iter().find_map(|fam| {
|
||||
faces
|
||||
.iter()
|
||||
.filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(fam) && f.covers(c))
|
||||
.min_by_key(|f| (f.italic, (f.weight - 400.0).abs() as i32))
|
||||
.min_by(|a, b| style_distance(a, weight, italic).total_cmp(&style_distance(b, weight, italic)))
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
@@ -2268,14 +2308,27 @@ impl FontDb {
|
||||
if families.is_empty() || !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
|
||||
return false;
|
||||
}
|
||||
families.iter().any(|fam| !self.is_loaded(fam) && self.load_cataloged(fam) && self.family_fallback(c, 0, &[fam]).is_some())
|
||||
families.iter().any(|fam| !self.is_loaded(fam) && self.load_cataloged(fam) && self.family_fallback(c, 0, &[fam], 400.0, false).is_some())
|
||||
}
|
||||
|
||||
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
/// The first loaded family (the fallback family, then load order) with a face covering `c`,
|
||||
/// and in it the face nearest `weight` and `italic`.
|
||||
fn loaded_fallback(&self, c: char, exclude: u32, weight: f32, italic: bool) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order.into_iter().find(|f| f.covers(c)).cloned()
|
||||
let mut families: Vec<&str> = Vec::new();
|
||||
for f in faces.iter().filter(|f| f.id != exclude && f.covers(c)) {
|
||||
if !families.iter().any(|fam| fam.eq_ignore_ascii_case(&f.family)) {
|
||||
families.push(&f.family);
|
||||
}
|
||||
}
|
||||
// Stable: the fallback family first, the others in load order.
|
||||
families.sort_by_key(|fam| !fam.eq_ignore_ascii_case(FALLBACK_FAMILY));
|
||||
let fam = families.first()?;
|
||||
faces
|
||||
.iter()
|
||||
.filter(|f| f.id != exclude && f.family.eq_ignore_ascii_case(fam) && f.covers(c))
|
||||
.min_by(|a, b| style_distance(a, weight, italic).total_cmp(&style_distance(b, weight, italic)))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
|
||||
@@ -1354,6 +1354,12 @@ fn optical_share(g: &SGlyph) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Roman Hanging Punctuation: the share of glyph `g`'s advance that hangs outside the margin when
|
||||
/// `g` starts or ends a line: [`optical_share`]'s punctuation, and no letters.
|
||||
fn roman_share(g: &SGlyph) -> f64 {
|
||||
if g.ch.is_alphabetic() { 0.0 } else { optical_share(g) }
|
||||
}
|
||||
|
||||
/// A punctuation mark that hangs ([`optical_share`]), not a letter.
|
||||
fn optical_punct(g: &SGlyph) -> bool {
|
||||
!g.ch.is_alphabetic() && optical_share(g) > 0.0
|
||||
@@ -1364,20 +1370,20 @@ fn is_quote(c: char) -> bool {
|
||||
matches!(c, '\'' | '"' | '‘' | '’' | '‚' | '‛' | '“' | '”' | '„' | '‟')
|
||||
}
|
||||
|
||||
/// Optical Margin Alignment: how far a line hangs outside its left and right margins, from the
|
||||
/// glyph at each end of `line` (its glyphs in visual order, each with the advance it is set with)
|
||||
/// and its [`optical_share`], and how many glyphs hang at each end → (left, right, left glyphs,
|
||||
/// Optical Margin Alignment and Roman Hanging Punctuation: how far a line hangs outside its left
|
||||
/// and right margins, from the glyph at each end of `line` (its glyphs in visual order, each with
|
||||
/// the advance it is set with) and its `share` ([`optical_share`] or [`roman_share`]), and how many glyphs hang at each end → (left, right, left glyphs,
|
||||
/// right glyphs). A punctuation mark followed by a quotation mark hangs together with it: `."`
|
||||
/// ending a line and `'"` starting one hang both their glyphs. A line of one glyph hangs at
|
||||
/// neither end.
|
||||
fn optical_hangs(line: &[(&SGlyph, f64)]) -> (f64, f64, usize, usize) {
|
||||
fn optical_hangs(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) {
|
||||
let n = line.len();
|
||||
if n < 2 {
|
||||
return (0.0, 0.0, 0, 0);
|
||||
}
|
||||
let hang = |k: usize| line.get(k).map_or(0.0, |&(g, adv)| optical_share(g) * adv.max(0.0));
|
||||
let hang = |k: usize| line.get(k).map_or(0.0, |&(g, adv)| share(g) * adv.max(0.0));
|
||||
let punct = |k: usize| line.get(k).is_some_and(|&(g, _)| optical_punct(g));
|
||||
let quote = |k: usize| line.get(k).is_some_and(|&(g, _)| is_quote(g.ch) && optical_share(g) > 0.0);
|
||||
let quote = |k: usize| line.get(k).is_some_and(|&(g, _)| is_quote(g.ch) && share(g) > 0.0);
|
||||
let mut left = hang(0);
|
||||
// The glyphs that hang at the left; the right end takes none of them.
|
||||
let mut taken = usize::from(left > 0.0);
|
||||
@@ -1843,15 +1849,18 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
// hang at both. A line that ends in a burasagari mark hangs only at its left.
|
||||
// Justification adds no space after a punctuation mark hanging at the left, nor before
|
||||
// one hanging at the right (`held`). A letter that hangs by part of its width keeps its
|
||||
// gaps.
|
||||
let (opt_l, opt_r, held) = if t.optical_margin && !rtl {
|
||||
// gaps. Roman Hanging Punctuation (a paragraph attribute) hangs the same punctuation
|
||||
// by the same amounts, and no letters; with Optical Margin Alignment on as well, the
|
||||
// letters hang too.
|
||||
let (opt_l, opt_r, held) = if (t.optical_margin || para.roman_hanging) && !rtl {
|
||||
let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share };
|
||||
let at: Vec<usize> = order.iter().map(|&offset| i + offset).filter(|&j| j < body).collect();
|
||||
let mut visible: Vec<(&SGlyph, f64)> =
|
||||
at.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect();
|
||||
if let Some(h) = &hyphen {
|
||||
visible.push((h, h.adv));
|
||||
}
|
||||
let (l, r, left_glyphs, right_glyphs) = optical_hangs(&visible);
|
||||
let (l, r, left_glyphs, right_glyphs) = optical_hangs(&visible, share);
|
||||
let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) };
|
||||
let n_visible = visible.len();
|
||||
let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g));
|
||||
|
||||
@@ -384,7 +384,7 @@ pub(crate) fn shape_range(
|
||||
} else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) {
|
||||
f.clone()
|
||||
} else {
|
||||
let f = db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone());
|
||||
let f = db.fallback_for_style(c, primary.id(), primary.weight, primary.italic).unwrap_or_else(|| primary.clone());
|
||||
cache.push((c, f.clone()));
|
||||
f
|
||||
};
|
||||
|
||||
@@ -405,6 +405,40 @@ fn fallback_font_per_character() {
|
||||
assert_eq!(l.glyphs[2].font_id, primary.id());
|
||||
}
|
||||
|
||||
/// Characters drawn from a fallback face keep the weight and slant asked for (#2491): bold text
|
||||
/// takes a bold face of the fallback family when it has one.
|
||||
#[test]
|
||||
fn fallback_glyphs_keep_the_weight_of_the_text() {
|
||||
let primary = db().face("Source Sans 3", "Bold").unwrap();
|
||||
let regular = db().face("Source Sans 3", "Regular").unwrap();
|
||||
let c = ['\u{2500}', '\u{2192}', '\u{25B6}', '\u{2588}', '\u{21E5}', '\u{2318}']
|
||||
.into_iter()
|
||||
.find(|&c| {
|
||||
!primary.covers(c)
|
||||
&& db()
|
||||
.fallback_for(c, regular.id())
|
||||
.is_some_and(|f| db().fallback_for_style(c, regular.id(), 700.0, false).is_some_and(|b| b.weight > f.weight))
|
||||
})
|
||||
.expect("some symbol covered by a family of the bundled fonts with more than one weight");
|
||||
let mut st = style(12.0);
|
||||
st.font_style = "Bold".into();
|
||||
let l = layout(db(), &point(&format!("a{c}b"), st));
|
||||
assert_eq!(l.glyphs[0].font_id, primary.id());
|
||||
let drawn = db().face_by_id(l.glyphs[1].font_id).unwrap();
|
||||
assert!(drawn.weight >= 600.0, "{} {} for a bold run", drawn.family, drawn.style);
|
||||
}
|
||||
|
||||
/// A missing family's Bold Italic is drawn bold when the fallback family has Bold and Italic but no
|
||||
/// Bold Italic, not as plain Italic (#2491).
|
||||
#[test]
|
||||
fn bold_italic_of_a_missing_family_keeps_the_bold() {
|
||||
let face = db().face("No Such Family", "Bold Italic").unwrap();
|
||||
assert_eq!(face.family, FALLBACK_FAMILY);
|
||||
assert!(face.weight >= 600.0, "{} {}", face.family, face.style);
|
||||
let italic = db().face("No Such Family", "Italic").unwrap();
|
||||
assert!(italic.italic && italic.weight < 600.0);
|
||||
}
|
||||
|
||||
/// A space the primary font doesn't map goes to the fallback face like any other character
|
||||
/// (#1844): an ideographic space in a face without U+3000 keeps its em, so an indent made with it
|
||||
/// isn't lost. A tab, which the layout handles itself, stays with the primary face. Needs a bundled
|
||||
@@ -907,6 +941,46 @@ fn optical_margin_alignment_hangs_punctuation_and_letter_edges_outside_the_margi
|
||||
assert_eq!(rtl(true), rtl(false));
|
||||
}
|
||||
|
||||
/// Roman Hanging Punctuation (a paragraph attribute) hangs the punctuation Optical Margin
|
||||
/// Alignment hangs, by the same amounts, and no letters; only the paragraphs that have it hang.
|
||||
#[test]
|
||||
fn roman_hanging_punctuation_hangs_punctuation_but_not_letters() {
|
||||
let frame = Rect::new(0.0, 0.0, 300.0, 400.0);
|
||||
let lay = |text: &str, justify: Justify, on: bool| {
|
||||
let mut t = area(text, style(20.0), frame, justify);
|
||||
t.para.roman_hanging = on;
|
||||
layout(db(), &t).glyphs
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
let end = |g: &PositionedGlyph| g.origin.x + g.advance;
|
||||
// Left aligned: the opening quotation mark hangs wholly.
|
||||
let on = lay("“Hello” there", Justify::Left, true);
|
||||
assert!(near(on[1].origin.x, 0.0) && near(on[0].origin.x, -on[0].advance) && on[0].advance > 1.0, "{:?}", on[0].origin);
|
||||
// An em dash hangs by half its advance.
|
||||
let dash = lay("—ab", Justify::Left, true);
|
||||
assert!(near(dash[0].origin.x, -0.5 * dash[0].advance), "{}", dash[0].origin.x);
|
||||
// Letters don't hang, where Optical Margin Alignment hangs an A by a tenth.
|
||||
assert!(near(lay("AVA", Justify::Left, true)[0].origin.x, 0.0));
|
||||
// Right aligned: the full stop and the closing quotation mark after it hang at the right.
|
||||
let r = lay("“Hi.”", Justify::Right, true);
|
||||
assert!(near(end(&r[2]), 300.0), "{}", end(&r[2]));
|
||||
// Justified: a hyphen at each end hangs, the words fill the frame between them.
|
||||
let j = lay("-ab cd-", Justify::JustifyAll, true);
|
||||
let n = j.len();
|
||||
assert!(near(j[1].origin.x, 0.0) && near(j[n - 1].origin.x, 300.0), "{} {}", j[1].origin.x, j[n - 1].origin.x);
|
||||
// Off: nothing hangs.
|
||||
assert!(near(lay("“Hello” there", Justify::Left, false)[0].origin.x, 0.0));
|
||||
// Per paragraph: the second paragraph, without it, doesn't hang.
|
||||
let mut t = area("“One\n“Two", style(20.0), frame, Justify::Left);
|
||||
let mut first = t.para.clone();
|
||||
first.roman_hanging = true;
|
||||
t.set_paragraph_styles(vec![first, t.para.clone()]);
|
||||
let l = layout(db(), &t);
|
||||
let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
|
||||
assert!(at(0) < -1.0, "first paragraph hangs: {}", at(0));
|
||||
assert!(near(at("“One\n".len()), 0.0), "second doesn't: {}", at("“One\n".len()));
|
||||
}
|
||||
|
||||
/// With craft-fonts and no installed fonts (the web), Thai is set in Noto Sans Thai, not as empty
|
||||
/// boxes. Skipped when built without craft-fonts or with a checkout that has no Thai font.
|
||||
#[test]
|
||||
|
||||
@@ -280,3 +280,43 @@ fn a_variable_full_name_names_its_default_face() {
|
||||
let bold = db.resolve("VaritestSans-Bold", "Regular").unwrap().0;
|
||||
assert_eq!(bold.variations(), [(*b"wght", 700.0)]);
|
||||
}
|
||||
|
||||
/// #2491: a variable font whose name table calls the default face `Light` (and whose OS/2 weight
|
||||
/// says 300) while its default instance is Regular at `wght` 400 lists, resolves and weighs the
|
||||
/// default face as Regular, as it renders.
|
||||
#[test]
|
||||
fn a_variable_default_face_is_named_and_weighed_by_its_default_instance() {
|
||||
let bytes = font();
|
||||
let source = skrifa::FontRef::new(&bytes).unwrap();
|
||||
let family = "Hebtest Sans";
|
||||
let names = name_table(&[
|
||||
(1, "Hebtest Sans Light"),
|
||||
(2, "Regular"),
|
||||
(4, "Hebtest Sans Light"),
|
||||
(6, "HebtestSans-Light"),
|
||||
(16, family),
|
||||
(17, "Light"),
|
||||
(256, "Weight"),
|
||||
(257, "Regular"),
|
||||
(258, "SemiBold"),
|
||||
(259, "Bold"),
|
||||
])
|
||||
.unwrap();
|
||||
let mut os2 = source.table_data(skrifa::raw::types::Tag::new(b"OS/2")).unwrap().as_bytes().to_vec();
|
||||
os2[4..6].copy_from_slice(&300_u16.to_be_bytes());
|
||||
let mut builder = write_fonts::FontBuilder::new();
|
||||
builder.add_raw(write_fonts::types::Tag::new(b"name"), names);
|
||||
builder.add_raw(write_fonts::types::Tag::new(b"OS/2"), os2);
|
||||
for record in source.table_directory.table_records() {
|
||||
let tag = write_fonts::types::Tag::new(&record.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, source.table_data(record.tag()).unwrap().as_bytes());
|
||||
}
|
||||
}
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(builder.build()), 3);
|
||||
assert_eq!(db.styles(family), ["Regular", "SemiBold", "Bold"]);
|
||||
let (face, matched) = db.resolve(family, "Regular").unwrap();
|
||||
assert_eq!((face.style.as_str(), face.weight, matched), ("Regular", 400.0, FontMatch::Exact));
|
||||
assert!(face.variations().is_empty());
|
||||
}
|
||||
@@ -11,8 +11,14 @@ mod visibility;
|
||||
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane};
|
||||
pub use visibility::{VisibleFace, visible_faces};
|
||||
mod shading;
|
||||
pub use shading::ShadeGradient;
|
||||
pub use shading::{LightGradient, RgbGradient, ShadeGradient};
|
||||
mod lights;
|
||||
pub use lights::{Light, Lights, MAX_LIGHTS};
|
||||
mod caps;
|
||||
#[cfg(test)]
|
||||
mod tests_lighting;
|
||||
#[cfg(test)]
|
||||
mod tests_multiple_lights;
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv};
|
||||
|
||||
@@ -67,9 +73,13 @@ pub struct Revolve {
|
||||
pub light_height: f64,
|
||||
/// The light is behind the object (Move light to the back of object) rather than in front.
|
||||
pub light_behind: bool,
|
||||
/// Opaque RGB light color, each channel 0..1. Ambient light stays neutral.
|
||||
pub light_color: [f64; 3],
|
||||
pub light_intensity: f64,
|
||||
/// Softness, 0 (a sharp fall-off) to 1: how far round the object the light reaches.
|
||||
pub light_softness: f64,
|
||||
/// Explicit light list. None retains the earlier single-light parameters exactly.
|
||||
pub lights: Option<Lights>,
|
||||
/// Ambient Light, 0 to 2.
|
||||
pub ambient: f64,
|
||||
pub shade: bool,
|
||||
@@ -98,8 +108,10 @@ impl Default for Revolve {
|
||||
light_rotation: DEFAULT_LIGHT.0,
|
||||
light_height: DEFAULT_LIGHT.1,
|
||||
light_behind: false,
|
||||
light_color: [1.0; 3],
|
||||
light_intensity: 0.8,
|
||||
light_softness: 0.0,
|
||||
lights: None,
|
||||
ambient: 0.25,
|
||||
shade: true,
|
||||
smooth_shading: true,
|
||||
@@ -156,11 +168,48 @@ fn brightness(o: &Revolve, n: V3, l: V3) -> f64 {
|
||||
if !o.shade {
|
||||
return 1.0;
|
||||
}
|
||||
if o.lights.is_some() {
|
||||
let rgb = illumination(o, n, l);
|
||||
return (rgb[0] + rgb[1] + rgb[2]) / 3.0;
|
||||
}
|
||||
let s = o.light_softness;
|
||||
let lit = ((n.dot(l) + s) / (1.0 + s)).max(0.0);
|
||||
(o.ambient + o.light_intensity * lit).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn direct_light(o: &Revolve, n: V3, l: V3) -> f64 {
|
||||
o.light_intensity * ((n.dot(l) + o.light_softness) / (1.0 + o.light_softness)).max(0.0)
|
||||
}
|
||||
|
||||
fn illumination(o: &Revolve, n: V3, l: V3) -> [f64; 3] {
|
||||
if !o.shade {
|
||||
return [1.0; 3];
|
||||
}
|
||||
if o.lights.is_some() {
|
||||
return unclipped_illumination(o, n, l).map(|v| v.clamp(0.0, 1.0));
|
||||
}
|
||||
let direct = direct_light(o, n, l);
|
||||
o.light_color.map(|c| (o.ambient + direct * c).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn unclipped_illumination(o: &Revolve, n: V3, legacy_light: V3) -> [f64; 3] {
|
||||
let mut rgb = [o.ambient; 3];
|
||||
if let Some(lights) = o.lights {
|
||||
for light in lights.iter() {
|
||||
let direct = light.direct(n);
|
||||
for (out, color) in rgb.iter_mut().zip(light.color) {
|
||||
*out += direct * color;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let direct = direct_light(o, n, legacy_light);
|
||||
for (out, color) in rgb.iter_mut().zip(o.light_color) {
|
||||
*out += direct * color;
|
||||
}
|
||||
}
|
||||
rgb
|
||||
}
|
||||
|
||||
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(min, max) } else { fallback }
|
||||
}
|
||||
@@ -255,6 +304,7 @@ impl Revolve {
|
||||
light_height: finite(self.light_height, DEFAULT_LIGHT.1, 0.0, 90.0),
|
||||
light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0),
|
||||
light_softness: finite(self.light_softness, 0.0, 0.0, 1.0),
|
||||
light_color: self.light_color.map(|c| finite(c, 1.0, 0.0, 1.0)),
|
||||
ambient: finite(self.ambient, 0.25, 0.0, 2.0),
|
||||
..self
|
||||
}
|
||||
@@ -277,6 +327,10 @@ pub struct Face {
|
||||
pub depth: f64,
|
||||
pub brightness: f64,
|
||||
pub gradient: Option<ShadeGradient>,
|
||||
/// RGB illumination and its unclipped smooth ramp, independent of the legacy white ramp.
|
||||
pub illumination: [f64; 3],
|
||||
pub light_gradient: Option<LightGradient>,
|
||||
pub rgb_gradient: Option<RgbGradient>,
|
||||
/// Curved vector boundary; points remain a bounded visibility approximation.
|
||||
pub outline: Option<PathData>,
|
||||
/// A single endpoint disk or sector, with its axis centre first in points.
|
||||
@@ -356,11 +410,38 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi
|
||||
} else {
|
||||
(flat_brightness, None)
|
||||
};
|
||||
let (illumination, light_gradient) = if let Some(normals) = normals {
|
||||
let normals: Vec<_> = normals.iter().map(|n| if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n }).collect();
|
||||
let mut rgb = [0.0; 3];
|
||||
for n in &normals {
|
||||
for (out, value) in rgb.iter_mut().zip(illumination(&o, *n, light)) {
|
||||
*out += value;
|
||||
}
|
||||
}
|
||||
let rgb = if normals.is_empty() { illumination(&o, normal, light) } else { rgb.map(|v| v / normals.len() as f64) };
|
||||
let ramp = if o.shade && o.lights.is_none() && o.light_color != [1.0; 3] {
|
||||
let values: Vec<_> = normals.iter().map(|n| direct_light(&o, *n, light)).collect();
|
||||
ShadeGradient::triangle(&points, &values).map(|diffuse| LightGradient { diffuse, ambient: o.ambient, color: o.light_color })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(rgb, ramp)
|
||||
} else {
|
||||
(illumination(&o, normal, light), None)
|
||||
};
|
||||
let rgb_gradient = normals.filter(|_| o.shade && o.lights.is_some()).and_then(|normals| {
|
||||
let values: Vec<_> =
|
||||
normals.iter().map(|n| unclipped_illumination(&o, if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n }, light)).collect();
|
||||
RgbGradient::triangle(&points, &values)
|
||||
});
|
||||
Ok(Some(Face {
|
||||
points,
|
||||
depth: vertices.iter().map(|v| v.2).sum::<f64>() / vertices.len() as f64,
|
||||
brightness,
|
||||
gradient,
|
||||
illumination,
|
||||
light_gradient,
|
||||
rgb_gradient,
|
||||
outline: None,
|
||||
cap: false,
|
||||
visibility_pieces: None,
|
||||
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user