mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-18 05:07:02 -04:00
Compare commits
84
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 | ||
|
|
39f9204d57 | ||
|
|
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
|
||||
|
||||
+2
-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 (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)
|
||||
> **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. |
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
@@ -464,6 +552,22 @@ pub struct ParaStyle {
|
||||
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
|
||||
/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto
|
||||
/// read the alignment and ignore the flag, and builds with it read Auto back.
|
||||
@@ -1371,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)]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
@@ -432,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();
|
||||
@@ -552,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)
|
||||
}
|
||||
|
||||
@@ -688,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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
+188
-2
@@ -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))
|
||||
}
|
||||
@@ -989,6 +1012,8 @@ impl Renderer {
|
||||
/// 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 shadow_raster(
|
||||
&mut self,
|
||||
@@ -1041,6 +1066,11 @@ impl Renderer {
|
||||
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
|
||||
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.
|
||||
@@ -1618,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,
|
||||
@@ -1702,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();
|
||||
@@ -2040,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,
|
||||
@@ -2053,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));
|
||||
@@ -2174,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};
|
||||
|
||||
@@ -2197,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();
|
||||
@@ -2720,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.
|
||||
@@ -2939,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 }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -197,14 +197,24 @@ fn in_scope(doc: &vectorcraft_doc::Document, scope: Option<NodeId>, id: NodeId)
|
||||
}
|
||||
|
||||
/// Visit the nodes Direct Selection may pick. In isolation mode that is the isolated object and
|
||||
/// its contents; art around it is locked and the walk does not reach it.
|
||||
/// its contents; art around it is locked and the walk does not reach it. A Shaper Group's original
|
||||
/// shapes, its first child, are left out unless they are the isolated object (Construction mode),
|
||||
/// as clicks leave them out.
|
||||
fn walk_pickable<'a>(doc: &'a vectorcraft_doc::Document, scope: Option<NodeId>, mut f: impl FnMut(&'a Node)) {
|
||||
fn walk<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
f(n);
|
||||
for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) {
|
||||
walk(c, f);
|
||||
}
|
||||
}
|
||||
if let Some(id) = scope
|
||||
&& let Some(n) = doc.node(id)
|
||||
{
|
||||
n.walk(&mut f);
|
||||
walk(n, &mut f);
|
||||
} else if scope.is_none() {
|
||||
doc.walk(f);
|
||||
for l in &doc.layers {
|
||||
walk(l, &mut f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -424,6 +424,22 @@ impl Targets {
|
||||
(cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx))
|
||||
}
|
||||
|
||||
/// The gridlines (one every `step` from the page's origin, as
|
||||
/// [`vectorcraft_geom::snap::snap_to_grid`] places them) at and either side of the nearest one
|
||||
/// to each of `points`, as lines to line up with.
|
||||
fn grid_lines(points: impl IntoIterator<Item = Point>, step: f64) -> Self {
|
||||
let mut t = Targets::default();
|
||||
let line = |v: f64, k: i32| vectorcraft_geom::snap::snap_to_grid(v, step, 0.0) + f64::from(k) * step;
|
||||
for p in points {
|
||||
for k in -1..=1 {
|
||||
let (x, y) = (line(p.x, k), line(p.y, k));
|
||||
t.xs.push((x, Point::new(x, p.y), Kind::Guide));
|
||||
t.ys.push((y, Point::new(p.x, y), Kind::Guide));
|
||||
}
|
||||
}
|
||||
t.index()
|
||||
}
|
||||
|
||||
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
|
||||
/// reach into `near`.
|
||||
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
|
||||
@@ -799,8 +815,8 @@ impl Targets {
|
||||
/// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own
|
||||
/// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page
|
||||
/// (labelled as [`Self::snap_point`] does), else the corners and the side it moves line up with
|
||||
/// the nearest target ("align"), whatever the box's angle. A handle with one way to go (a side,
|
||||
/// or a proportional corner) slides along it until one of them is on a target.
|
||||
/// the nearest target or ruler guide ("align"), whatever the box's angle. A handle with one way
|
||||
/// to go (a side, or a proportional corner) slides along it until one of them is on a target.
|
||||
pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec<Overlay>) {
|
||||
type Hit = Option<(f64, Point)>;
|
||||
// Where a snap lands: in line with a target's x and/or y, or on an anchor or centre.
|
||||
@@ -811,10 +827,16 @@ impl Targets {
|
||||
let (to_doc, to_local) = (bx.to_doc(), bx.to_doc().inverse());
|
||||
// A scale that collapses or flips the box is no snap.
|
||||
let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0;
|
||||
let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit {
|
||||
// The target line along the x axis (`x`: a vertical line) or the y axis nearest `p`, the
|
||||
// ruler guides running past it included.
|
||||
let nearest = |p: Point, x: bool| -> Hit {
|
||||
let (v, ts) = if x { (p.x, &self.xs) } else { (p.y, &self.ys) };
|
||||
let rulers = self.rulers.iter().filter(|r| r.vertical == x).filter_map(|r| r.line_at(p, tol));
|
||||
lines_near(ts, v, tol)
|
||||
.iter()
|
||||
.map(|(t, from, _)| (*t, *from))
|
||||
.copied()
|
||||
.chain(rulers)
|
||||
.map(|(t, from, _)| (t, from))
|
||||
.filter(|(t, _)| (t - v).abs() <= tol)
|
||||
.min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
|
||||
};
|
||||
@@ -824,7 +846,7 @@ impl Targets {
|
||||
// The corner goes anywhere: onto an anchor or centre, else it takes the target's x, its
|
||||
// y, or both.
|
||||
let p = to_doc * (a * handle.pos(r));
|
||||
let (hx, hy) = (nearest(p.x, &self.xs), nearest(p.y, &self.ys));
|
||||
let (hx, hy) = (nearest(p, true), nearest(p, false));
|
||||
let on = self.nearest_point(p, tol).map(|(q, k)| (Some((q.x, q)), Some((q.y, q)), Some((q, k))));
|
||||
for (tx, ty, at) in on.into_iter().chain([(hx, hy, None), (hx, None, None), (None, hy, None)]) {
|
||||
if tx.is_none() && ty.is_none() {
|
||||
@@ -878,11 +900,11 @@ impl Targets {
|
||||
{
|
||||
consider((q - base).dot(dir) / l2, false, (None, None, Some((q, k))));
|
||||
}
|
||||
for (is_x, at, b, d, ts) in [(true, p.x, base.x, dir.x, &self.xs), (false, p.y, base.y, dir.y, &self.ys)] {
|
||||
for (is_x, b, d) in [(true, base.x, dir.x), (false, base.y, dir.y)] {
|
||||
if d.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let Some(hit) = nearest(at, ts) else { continue };
|
||||
let Some(hit) = nearest(p, is_x) else { continue };
|
||||
consider((hit.0 - b) / d, true, if is_x { (Some(hit), None, None) } else { (None, Some(hit), None) });
|
||||
}
|
||||
}
|
||||
@@ -980,6 +1002,38 @@ impl PointSnap {
|
||||
None => (keep(p), vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a bounding-box resize ([`Targets::snap_scale`]) as [`Self::snap`] snaps a point. With
|
||||
/// Snap to Pixel on, the corner or side the handle moves lines up with whole pixels, and with
|
||||
/// Snap to Grid on it lines up with the nearest gridline, however far that is; neither shows
|
||||
/// guides. Otherwise the handle snaps to the targets [`Self::new`] gathered.
|
||||
pub fn snap_scale(
|
||||
&self,
|
||||
cx: &ToolContext,
|
||||
bx: &OrientedBox,
|
||||
handle: Handle,
|
||||
a: Affine,
|
||||
proportional: bool,
|
||||
from_center: bool,
|
||||
) -> (Affine, Vec<Overlay>) {
|
||||
let step = if cx.snap_to_pixel {
|
||||
Some(1.0)
|
||||
} else if cx.snap_to_grid {
|
||||
Some(cx.grid_step()).filter(|s| s.is_finite() && *s > 0.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(step) = step {
|
||||
let moved = Handle::ALL.map(|h| bx.to_doc() * (a * h.pos(bx.rect)));
|
||||
// Every point the handle moves is within half a step of a gridline, so a tolerance of
|
||||
// one step always finds one.
|
||||
return (Targets::grid_lines(moved, step).snap_scale(bx, handle, a, proportional, from_center, step).0, vec![]);
|
||||
}
|
||||
match &self.targets {
|
||||
Some((t, tol)) => t.snap_scale(bx, handle, a, proportional, from_center, cx.tol(*tol)),
|
||||
None => (a, vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Construction Guides angle set new preferences start with.
|
||||
|
||||
+100
-6
@@ -1,7 +1,8 @@
|
||||
//! The Selection tool (V): click/shift-click, marquee (Shift-drag toggles the objects it reaches:
|
||||
//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! bounding-box scale (Shift proportional, Alt from center; Snap to Grid, Snap to Pixel, Smart
|
||||
//! Guides and Snap to Point snap what the handle moves) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), drag a live ellipse's pie widgets to set its pie angles
|
||||
//! (double-click makes it whole again: [`crate::pie`]), double-click a group or an object to enter isolation
|
||||
@@ -28,7 +29,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::Targets;
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::pie::{self, PieDrag};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
@@ -91,7 +92,7 @@ pub struct SelectionTool {
|
||||
measure: Option<(Point, String)>,
|
||||
guides: Vec<Overlay>,
|
||||
/// What a bounding-box handle being dragged snaps to.
|
||||
targets: Option<Targets>,
|
||||
targets: Option<PointSnap>,
|
||||
moving: Option<MoveSnap>,
|
||||
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
|
||||
snapped: bool,
|
||||
@@ -436,8 +437,10 @@ impl Tool for SelectionTool {
|
||||
let areas = cx.selection.objects.iter().any(is_area);
|
||||
// Only area type: the step resizes type areas; anything else scales.
|
||||
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
|
||||
// Smart Guides align what the handle moves with the other objects.
|
||||
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
|
||||
// What the handle moves snaps as a dragged point does: to pixels or the
|
||||
// grid, else with Smart Guides to the other objects, else with Snap to
|
||||
// Point to their anchors; the ruler guides with Snap to Point.
|
||||
self.targets = Some(PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
|
||||
self.state = State::Scaling { handle, bx, areas };
|
||||
return vec![Action::Begin(label.into())];
|
||||
}
|
||||
@@ -514,7 +517,7 @@ impl Tool for SelectionTool {
|
||||
let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||
self.guides.clear();
|
||||
if let Some(t) = &self.targets {
|
||||
(a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.snap_tol());
|
||||
(a, self.guides) = t.snap_scale(cx, &bx, handle, a, m.shift, m.alt);
|
||||
}
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
|
||||
@@ -1451,6 +1454,97 @@ mod tests {
|
||||
assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}");
|
||||
}
|
||||
|
||||
/// The box the 100–200 rectangle's bounds become under the scale a handle drag previews.
|
||||
fn scaled(a: &[Action]) -> Rect {
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0))
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Grid on, a handle drag lands what the handle moves on the nearest
|
||||
/// gridline (every 9 pt by default) while the opposite side stays put; Shift keeps the
|
||||
/// proportions, and Alt keeps the center.
|
||||
#[test]
|
||||
fn handle_drag_lands_what_it_moves_on_the_grid() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
let drag = |from: (f64, f64), to: (f64, f64), mods: Mods| {
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)), vec![Action::Begin("Scale".into())]);
|
||||
scaled(&t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)))
|
||||
};
|
||||
let none = Mods::default();
|
||||
// The right side, dragged to x = 213, lands on the gridline at 216.
|
||||
let r = drag((200.0, 150.0), (213.0, 150.0), none);
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 200.0)), "{r:?}");
|
||||
// The bottom-right corner, dragged to (213, 204), lands on (216, 207).
|
||||
let r = drag((200.0, 200.0), (213.0, 204.0), none);
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 207.0)), "{r:?}");
|
||||
// Shift: the corner, at (213, 213) in proportion, lands on (216, 216), still square.
|
||||
let r = drag((200.0, 200.0), (213.0, 204.0), Mods { shift: true, ..none });
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 216.0)), "{r:?}");
|
||||
// Alt: the right side lands on 216 and the left side mirrors it about the center.
|
||||
let r = drag((200.0, 150.0), (213.0, 150.0), Mods { alt: true, ..none });
|
||||
assert!(near(r, Rect::new(84.0, 100.0, 216.0, 200.0)), "{r:?}");
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Pixel on, what a handle drag moves lands on whole pixels.
|
||||
#[test]
|
||||
fn handle_drag_lands_what_it_moves_on_whole_pixels() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) };
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let r = scaled(&t.pointer(&c, &ev(PointerKind::Drag, 212.4, 230.7)));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}");
|
||||
}
|
||||
|
||||
/// #2054: with Smart Guides off, View › Snap to Point lands a dragged handle on another
|
||||
/// object's anchor or a ruler guide within its distance (2 px); with Smart Guides on, the
|
||||
/// ruler guides still pull it within the Snapping Tolerance (4 px), as a move does (#1534).
|
||||
#[test]
|
||||
fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let other = d.alloc_id();
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0));
|
||||
d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap();
|
||||
d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)];
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let drag = |c: &ToolContext, from: (f64, f64), to: (f64, f64)| {
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(c, &ev(PointerKind::Down, from.0, from.1));
|
||||
scaled(&t.pointer(c, &ev(PointerKind::Drag, to.0, to.1)))
|
||||
};
|
||||
let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) };
|
||||
assert!(off.snap_to_point);
|
||||
// The corner, 1.4 px from the other square's corner, lands on it.
|
||||
let r = drag(&off, (200.0, 200.0), (249.0, 251.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 250.0, 250.0)), "{r:?}");
|
||||
// The right side, 1 px from the ruler guide, lands on it.
|
||||
let r = drag(&off, (200.0, 150.0), (231.5, 150.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}");
|
||||
// Snap to Point off: the side follows the pointer.
|
||||
let r = drag(&ToolContext { snap_to_point: false, ..off }, (200.0, 150.0), (231.5, 150.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 231.5, 200.0)), "{r:?}");
|
||||
// Smart Guides on: the ruler guide, 2.5 px away, pulls the side too.
|
||||
let r = drag(&cx(&d, &s, &p), (200.0, 150.0), (233.0, 150.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}");
|
||||
}
|
||||
|
||||
/// Smart Guides › Measurement Labels, Transform Tools and Snapping Tolerance (#394): the
|
||||
/// readouts while moving and while scaling each follow their option; the tolerance sets how
|
||||
/// far a dragged handle is pulled.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Shows the bounding box with handles. In the default mode handles scale (Shift proportional,
|
||||
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
|
||||
//! shears along that side. The Perspective Distort and Free Distort modes (tool option `mode`, or
|
||||
//! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or
|
||||
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
|
||||
//! box and preview `object.distort`.
|
||||
|
||||
@@ -11,6 +11,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::{BLUE, polygon, rect_corners};
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::select::{matrix_json, selection_bounds};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
@@ -82,8 +83,10 @@ pub struct FreeTransformTool {
|
||||
/// The current (distorted) quad while dragging corners.
|
||||
quad: Option<[Point; 4]>,
|
||||
measure: Option<(Point, String)>,
|
||||
/// Smart Guides for a scale: the other objects' edges, and the lines of the current snap.
|
||||
targets: Option<crate::guides::Targets>,
|
||||
/// What a scale snaps to ([`PointSnap::snap_scale`]): pixels, the grid, the other objects with
|
||||
/// Smart Guides, or their anchors with Snap to Point.
|
||||
targets: Option<PointSnap>,
|
||||
/// The lines of the current snap.
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ impl FreeTransformTool {
|
||||
Op::Scale(h) => {
|
||||
let mut a = scale_for_drag(d.rect, h, p, shift, m.alt);
|
||||
if let Some(t) = &self.targets {
|
||||
(a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.snap_tol());
|
||||
(a, self.guides) = t.snap_scale(cx, &vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt);
|
||||
}
|
||||
let nr = a.transform_rect_bbox(d.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
|
||||
@@ -232,8 +235,7 @@ impl Tool for FreeTransformTool {
|
||||
self.drag = None;
|
||||
let Some(r) = selection_bounds(cx) else { return vec![] };
|
||||
if let Some(op) = self.classify(cx, r, p, ev.mods) {
|
||||
self.targets = (cx.smart_guides && op.handle().is_some())
|
||||
.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
|
||||
self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
|
||||
self.drag = Some(Drag { op, rect: r, start: p, began: false });
|
||||
}
|
||||
vec![]
|
||||
@@ -384,6 +386,27 @@ mod tests {
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["matrix"][4] == 10.0 && v["matrix"][5] == 20.0));
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Grid on, a handle drag lands the side it moves on the nearest gridline
|
||||
/// (every 9 pt by default) while the opposite side stays put.
|
||||
#[test]
|
||||
fn handle_drag_snaps_to_the_grid() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
let matrix = |a: &[Action]| {
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])
|
||||
};
|
||||
let mut t = FreeTransformTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 150.0));
|
||||
let r = matrix(&t.pointer(&cx, &ev(PointerKind::Drag, 213.0, 150.0))).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
assert!((r.x1 - 216.0).abs() < 1e-9 && (r.x0 - 100.0).abs() < 1e-9, "{r:?}");
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 213.0, 150.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// In a view turned 90° the handles' cursors turn with the view.
|
||||
#[test]
|
||||
fn handle_cursors_turn_with_the_view() {
|
||||
|
||||
+130
-29
@@ -3,6 +3,7 @@
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::text::TextKind;
|
||||
use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
|
||||
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
|
||||
@@ -315,6 +316,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
isolated: st.isolation.map(|id| id.0),
|
||||
// Appearance of Black › On Screen: Display All Blacks as Rich Black.
|
||||
rich_black: app.session.prefs.black_on_screen == "rich",
|
||||
// A tool's drag on the canvas, during an interaction (not a dialog's preview).
|
||||
dragging: app.session.in_interaction() && ui.data(|d| d.get_temp::<Drag>(drag_id())) == Some(Drag::Tool),
|
||||
};
|
||||
// Placed documents' bitmaps are being made: draw again when they are ready.
|
||||
if vectorcraft_render::placed_document::busy() {
|
||||
@@ -376,6 +379,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// During a drag or a dialog's preview, objects that only moved draw their raster
|
||||
// effects from the cached rasters of an earlier frame (#1257).
|
||||
interacting: key.interacting,
|
||||
// During a tool's drag, raster effects without a cached raster to draw render at a
|
||||
// lower resolution (#2558); the frame after the drag renders them as a still frame does.
|
||||
dragging: key.dragging,
|
||||
rich_black: key.rich_black,
|
||||
..opts
|
||||
};
|
||||
@@ -2854,30 +2860,44 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
let n = st.selection.len();
|
||||
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned();
|
||||
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
|
||||
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
|
||||
let is_image = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Image(_)));
|
||||
let is_clip_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { clip: true, .. }));
|
||||
let is_compound = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Compound { .. }));
|
||||
let open_path = first.as_ref().is_some_and(|f| matches!(&f.kind, NodeKind::Path { path, .. } if !path.is_closed()));
|
||||
let text = first.as_ref().and_then(|f| match &f.kind {
|
||||
NodeKind::Text(t) => Some(&t.kind),
|
||||
_ => None,
|
||||
});
|
||||
// Images and vector art selected together: a clipping mask is the likely next step.
|
||||
let mixed = n > 1 && {
|
||||
let images = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|o| matches!(o.kind, NodeKind::Image(_))).count();
|
||||
images > 0 && images < n
|
||||
};
|
||||
// An Image Trace object: the group the engine names "Image Trace", its source image first.
|
||||
let is_trace = is_group
|
||||
&& first.as_ref().is_some_and(|f| {
|
||||
f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
|
||||
});
|
||||
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
|
||||
let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params)
|
||||
let none = || json!({});
|
||||
if on_artboards {
|
||||
// As in the reference app, the Artboard tool offers to resize, duplicate and export the
|
||||
// artboards (<https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>),
|
||||
// and to rearrange them (<https://creativepro.com/managing-artboards-in-illustrator/>).
|
||||
items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS));
|
||||
items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS));
|
||||
items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS, none()));
|
||||
items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS, none()));
|
||||
if st.doc.artboards.len() > 1 {
|
||||
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS));
|
||||
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none()));
|
||||
}
|
||||
items.push((tl!("Export"), "share-2", "file.exportForScreens"));
|
||||
items.push((tl!("Export"), "share-2", "file.exportForScreens", none()));
|
||||
} else {
|
||||
// Edit Path: the Direct Selection tool, to edit the path's anchors.
|
||||
let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"}));
|
||||
// As in the reference app, an image offers Image Trace and Crop Image, and traced art offers
|
||||
// Expand (<https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>).
|
||||
if n == 1 && is_image {
|
||||
items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE));
|
||||
items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage"));
|
||||
items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none()));
|
||||
items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none()));
|
||||
}
|
||||
// Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar
|
||||
// offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does
|
||||
@@ -2885,29 +2905,52 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
// These run the same commands as the Control bar's anchor buttons.
|
||||
let anchors = !st.selection.anchors.is_empty();
|
||||
if anchors {
|
||||
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors"));
|
||||
items.push((tl!("Connect"), "dc-join", "path.join"));
|
||||
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors"));
|
||||
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER));
|
||||
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH));
|
||||
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none()));
|
||||
items.push((tl!("Connect"), "dc-join", "path.join", none()));
|
||||
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none()));
|
||||
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
|
||||
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
|
||||
}
|
||||
// As in the reference app, open paths offer Join and Edit Path, compound paths and clipping
|
||||
// masks their Release, images selected with vector art Make Clipping Mask, and type converts
|
||||
// between point and area type (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
|
||||
if n > 1 {
|
||||
items.push((tl!("Group"), "group", "object.group"));
|
||||
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite"));
|
||||
items.push((tl!("Group"), "group", "object.group", none()));
|
||||
if mixed {
|
||||
items.push((tl!("Make Clipping Mask"), "dc-pf-crop", "object.clippingMask.make", none()));
|
||||
} else {
|
||||
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite", none()));
|
||||
}
|
||||
} else if is_trace {
|
||||
items.push((tl!("Expand"), "maximize-2", "imageTrace.expand"));
|
||||
items.push((tl!("Expand"), "maximize-2", "imageTrace.expand", none()));
|
||||
} else if is_clip_group {
|
||||
items.push((tl!("Edit Clipping Path"), "dc-direct", "object.clippingMask.editMask", none()));
|
||||
items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none()));
|
||||
} else if is_group {
|
||||
items.push((tl!("Ungroup"), "ungroup", "object.ungroup"));
|
||||
items.push((tl!("Isolate"), "square-dashed", "object.isolate"));
|
||||
} else if is_text {
|
||||
items.push((tl!("Create Outlines"), "type", "type.createOutlines"));
|
||||
items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none()));
|
||||
items.push((tl!("Isolate"), "square-dashed", "object.isolate", none()));
|
||||
} else if is_compound {
|
||||
items.push(edit_path());
|
||||
items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none()));
|
||||
} else if let Some(kind) = text {
|
||||
items.push((tl!("Create Outlines"), "type", "type.createOutlines", none()));
|
||||
// Point type converts to area type and back; type on a path doesn't.
|
||||
match kind {
|
||||
TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())),
|
||||
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
|
||||
TextKind::OnPath { .. } => {}
|
||||
}
|
||||
} else if is_image {
|
||||
// Offset Path and Simplify don't apply to an image.
|
||||
} else if !anchors {
|
||||
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath"));
|
||||
items.push((tl!("Simplify"), "spline", "object.path.simplify"));
|
||||
if open_path {
|
||||
items.push((tl!("Join"), "dc-join", "path.join", none()));
|
||||
}
|
||||
items.push(edit_path());
|
||||
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath", none()));
|
||||
items.push((tl!("Simplify"), "spline", "object.path.simplify", none()));
|
||||
}
|
||||
items.push((tl!("Duplicate"), "copy", "edit.duplicate"));
|
||||
items.push((tl!("Duplicate"), "copy", "edit.duplicate", none()));
|
||||
}
|
||||
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
|
||||
let colored = has_colors(&st.doc, &st.selection.objects);
|
||||
@@ -2916,7 +2959,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
// rotated).
|
||||
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
|
||||
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
|
||||
let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
|
||||
let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
|
||||
// Its place under the selection, then where the handle moved it, kept on the canvas (with the
|
||||
// bar's size last frame, the estimate before it first shows).
|
||||
let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0);
|
||||
@@ -2962,7 +3005,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
} else if grip.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
for (label, icon, cmd) in &items {
|
||||
for (label, icon, cmd, params) in &items {
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
@@ -2972,7 +3015,7 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
|
||||
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
|
||||
if resp.clicked() {
|
||||
run = Some((*cmd, json!({})));
|
||||
run = Some((*cmd, params.clone()));
|
||||
}
|
||||
}
|
||||
// The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool.
|
||||
@@ -4368,7 +4411,7 @@ mod tests {
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap();
|
||||
assert!(upload(&mut app).is_none(), "not rendered on this thread");
|
||||
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker");
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let mut after = None;
|
||||
while after.is_none() && std::time::Instant::now() < until {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
@@ -4383,6 +4426,64 @@ mod tests {
|
||||
assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws");
|
||||
}
|
||||
|
||||
/// A tool's drag on the canvas renders with `dragging` (#2558). At 3200% a rectangle with
|
||||
/// Texturizer covers more than the raster budget, so no raster is kept for it, and each frame
|
||||
/// of a Selection tool drag draws its filtered content at the drag's lower resolution. The
|
||||
/// frame after the drag draws what a fresh renderer draws. A dialog's preview, an interaction
|
||||
/// without a drag, renders without `dragging` and draws the same.
|
||||
#[test]
|
||||
fn a_drag_on_the_canvas_draws_raster_effects_at_a_lower_resolution() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.canvas.worker_started = true; // render on this thread
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150})).unwrap()["id"].clone();
|
||||
app.run("effect.apply", json!({"effect": "texture.texturizer", "ids": [id]})).unwrap();
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
// One headless frame on a 1920 × 1200 window (a view larger than the drag's budget) with
|
||||
// `events` → the art's raster it uploaded, None when it was not re-rendered.
|
||||
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| -> Option<Vec<Color32>> {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1200.0))), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
let art = app.canvas.texture.as_ref().map(|t| t.id());
|
||||
let px = out.textures_delta.set.iter().find(|e| Some(*e.0) == art).and_then(|e| e.1.last()).map(|d| {
|
||||
let egui::ImageData::Color(img) = &d.image;
|
||||
img.pixels.clone()
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
px
|
||||
};
|
||||
run(&mut app, vec![]);
|
||||
if let Some(vm) = app.view_mut() {
|
||||
(vm.zoom, vm.center) = (32.0, Point::new(200.0, 155.0));
|
||||
}
|
||||
let still = run(&mut app, vec![]).expect("the zoomed view renders");
|
||||
assert_eq!(app.canvas.renderer.stats.previewed, 0, "a still frame");
|
||||
let at = app.canvas_rect.unwrap().center();
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
run(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||||
run(&mut app, vec![button(at, true)]);
|
||||
for step in [1.0, 2.0] {
|
||||
let drawn = run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]).expect("each frame of the drag renders");
|
||||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}");
|
||||
assert_eq!(app.canvas.renderer.stats.previewed, 1, "the filtered content, step {step}");
|
||||
assert!(app.canvas.moved);
|
||||
assert!(drawn != still, "the rectangle moved, step {step}");
|
||||
}
|
||||
let after = run(&mut app, vec![button(at + vec2(26.0, 14.0), false)]).expect("the frame after the drag renders");
|
||||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting && !k.dragging));
|
||||
assert_eq!(app.canvas.renderer.stats.previewed, 0, "the frame after the drag");
|
||||
assert!(!app.canvas.moved);
|
||||
app.canvas.renderer = vectorcraft_render::Renderer::new();
|
||||
app.canvas.key = None;
|
||||
assert!(run(&mut app, vec![]) == Some(after.clone()), "the frame after the drag is what a fresh renderer draws");
|
||||
// A dialog's preview: an interaction, and no drag.
|
||||
app.session.begin_interaction("Texturizer").unwrap();
|
||||
assert!(run(&mut app, vec![]) == Some(after), "a dialog's preview draws the effect at full resolution");
|
||||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && !k.dragging));
|
||||
assert_eq!(app.canvas.renderer.stats.previewed, 0);
|
||||
}
|
||||
|
||||
/// The frame after an interaction renders without `interacting` also when the interaction
|
||||
/// leaves the document as it was (#1257): here a drag back to where it started.
|
||||
#[test]
|
||||
@@ -6573,7 +6674,7 @@ mod tests {
|
||||
// A selected object with the Selection tool: its own actions, none of the artboards'.
|
||||
app.select_tool("selection");
|
||||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
|
||||
assert_eq!(names(&mut app), ["Offset Path", "Simplify", "Duplicate"]);
|
||||
assert_eq!(names(&mut app), ["Edit Path", "Offset Path", "Simplify", "Duplicate"]);
|
||||
|
||||
// One artboard: nothing to rearrange.
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
@@ -6675,7 +6776,7 @@ mod tests {
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let mut square =
|
||||
|x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let (a, b) = (square(120.0, 40.0), square(220.0, 150.0));
|
||||
let (a, b) = (square(120.0, 40.0), square(160.0, 150.0));
|
||||
app.select_tool("selection");
|
||||
let ctx = egui::Context::default();
|
||||
let bar = || task_bar_rect(&ctx).expect("the task bar shows");
|
||||
|
||||
@@ -131,7 +131,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
/// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML
|
||||
/// output, else the image); the web downloads them.
|
||||
/// output, else the image); the web downloads them. The status bar shows the export's first
|
||||
/// warning.
|
||||
pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
|
||||
// Bad settings never open a save panel.
|
||||
let s = app.session.web_settings(&p)?;
|
||||
@@ -145,8 +146,11 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
|
||||
io::write_to(&mut app.services, name, &bytes)?;
|
||||
names.push(json!(name));
|
||||
}
|
||||
app.status(format!("Downloaded {} file(s)", names.len()));
|
||||
return Ok(json!({ "files": names, "bytes": r["bytes"] }));
|
||||
match names.first().and_then(Value::as_str).filter(|_| has_warnings(&r)) {
|
||||
Some(first) => io::report_saved(app, first, &r),
|
||||
None => app.status(format!("Downloaded {} file(s)", names.len())),
|
||||
}
|
||||
return Ok(json!({ "files": names, "bytes": r["bytes"], "warnings": r["warnings"] }));
|
||||
}
|
||||
let ext = if s.output == WebOutput::Html { "html" } else { s.format.ext() };
|
||||
let path = io::target_path(app, path, ext)?;
|
||||
@@ -155,12 +159,23 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
|
||||
}
|
||||
let r = app.run("document.exportForWeb", p)?;
|
||||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||||
app.status(if n > 1 { format!("Saved {n} files beside {path}") } else { format!("Saved {path}") });
|
||||
if n > 1 && !has_warnings(&r) {
|
||||
app.status(format!("Saved {n} files beside {path}"));
|
||||
} else {
|
||||
io::report_saved(app, &path, &r);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Whether an export's reply `r` has warnings (an image that couldn't be decoded):
|
||||
/// [`io::report_saved`] puts the first in the status bar as a save's note.
|
||||
fn has_warnings(r: &Value) -> bool {
|
||||
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// `file.saveForWeb.browser`: the HTML page and its images in a temporary folder, opened in the
|
||||
/// default browser (desktop).
|
||||
/// default browser (desktop). When the export has warnings, the status bar shows the first in
|
||||
/// place of "Opened …".
|
||||
pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if io::is_web(app) {
|
||||
return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into());
|
||||
@@ -173,9 +188,12 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Val
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("output".into(), json!("html"));
|
||||
}
|
||||
app.run("document.exportForWeb", q)?;
|
||||
let r = app.run("document.exportForWeb", q)?;
|
||||
app.open_url(&io::file_url(&path));
|
||||
Ok(json!({ "path": path }))
|
||||
if has_warnings(&r) {
|
||||
io::report_saved(app, &path, &r);
|
||||
}
|
||||
Ok(json!({ "path": path, "warnings": r["warnings"] }))
|
||||
}
|
||||
|
||||
/// The folder browser previews go to.
|
||||
|
||||
@@ -133,3 +133,25 @@ fn save_for_office_documents_picks_the_resolution() {
|
||||
let img = image::load_from_memory(&written.borrow()[1].1).unwrap().to_rgba8();
|
||||
assert_eq!((img.dimensions(), img.get_pixel(0, 0).0), ((60, 40), [255, 255, 255, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_for_office_and_export_selection_say_when_an_image_could_not_be_decoded() {
|
||||
const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture";
|
||||
let (mut app, written) = app(1);
|
||||
app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap();
|
||||
assert_eq!(app.ui.status, "Saved /x/o.png");
|
||||
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short.
|
||||
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);
|
||||
app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [10, 10]})).unwrap();
|
||||
app.run("document.exportForOffice", json!({"path": "/x/o.png", "ppi": 72})).unwrap();
|
||||
assert_eq!(app.ui.status, format!("Saved /x/o.png {NOTE}"));
|
||||
// Export Selection of the placed image, which stays selected.
|
||||
app.run("document.exportSelection", json!({"path": "/x/cut.png"})).unwrap();
|
||||
assert_eq!(app.ui.status, format!("Saved /x/cut.png {NOTE}"));
|
||||
assert_eq!(written.borrow().len(), 3, "the files are still written");
|
||||
}
|
||||
@@ -141,6 +141,38 @@ fn preview_in_browser_opens_the_page() {
|
||||
assert!(log.last().unwrap().starts_with("open file://") && log.last().unwrap().ends_with("Untitled-1.html"), "{log:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
|
||||
const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture";
|
||||
// A PNG whose header reads but whose pixel data is cut short: the renderer draws nothing for it.
|
||||
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);
|
||||
let place = |app: &mut VectorcraftApp| {
|
||||
app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [30, 20]})).unwrap()
|
||||
};
|
||||
let dir = vectorcraft_testkit::temp_dir("sfw-undecoded");
|
||||
let (mut desktop, _) = app(&dir, false);
|
||||
desktop.run("file.saveForWeb", json!({"format": "png24"})).unwrap();
|
||||
assert!(!desktop.ui.status.contains("note"), "{}", desktop.ui.status);
|
||||
place(&mut desktop);
|
||||
for p in [json!({"format": "png24"}), json!({"format": "jpg", "output": "html"})] {
|
||||
desktop.status("");
|
||||
desktop.run("file.saveForWeb", p.clone()).unwrap();
|
||||
assert!(desktop.ui.status.starts_with("Saved ") && desktop.ui.status.ends_with(NOTE), "{p}: {}", desktop.ui.status);
|
||||
}
|
||||
desktop.status("");
|
||||
desktop.run("file.saveForWeb.browser", json!({"format": "gif"})).unwrap();
|
||||
assert!(desktop.ui.status.ends_with(NOTE), "Preview in Browser: {}", desktop.ui.status);
|
||||
let (mut web, _) = app(&dir, true);
|
||||
place(&mut web);
|
||||
web.run("file.saveForWeb", json!({"format": "png8"})).unwrap();
|
||||
assert!(web.ui.status.ends_with(NOTE), "download: {}", web.ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_table_buttons_edit_the_lists() {
|
||||
let mut d = Dialog::new(save_for_web::KIND, json!({}));
|
||||
|
||||
@@ -249,6 +249,43 @@ fn the_web_downloads_one_zip_or_the_file() {
|
||||
assert_eq!(image::load_from_memory(&log[0].1).unwrap().to_rgba8().get_pixel(1, 1).0, [0, 0, 0, 255], "the PNG settings' background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_bar_says_when_an_image_could_not_be_decoded() {
|
||||
const NOTE: &str = "with 1 note(s): an image couldn't be decoded and is missing from the picture";
|
||||
// A PNG whose header reads but whose pixel data is cut short: the renderer draws nothing for it.
|
||||
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);
|
||||
let place = |app: &mut VectorcraftApp| {
|
||||
app.run("file.place", json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [10, 10]})).unwrap()
|
||||
};
|
||||
let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "jpg", "scale": "2x", "suffix": "@2x"}]);
|
||||
let dir = temp_folder("undecoded");
|
||||
let folder = dir.to_string_lossy().replace('\\', "/");
|
||||
let (mut desktop, log) = app(1, false);
|
||||
desktop.run("file.exportForScreens", json!({"folder": folder, "formats": rows})).unwrap();
|
||||
assert_eq!(desktop.ui.status, format!("Exported 2 file(s) to {folder}"));
|
||||
place(&mut desktop);
|
||||
desktop.run("file.exportForScreens", json!({"folder": folder, "formats": rows, "openLocation": true})).unwrap();
|
||||
assert_eq!(desktop.ui.status, format!("Saved {folder} {NOTE}"));
|
||||
assert_eq!(log.borrow().len(), 1, "Open Location still shows the first file");
|
||||
let r = desktop.run("file.exportForScreens", json!({"folder": folder, "formats": rows, "zip": true})).unwrap();
|
||||
assert_eq!(desktop.ui.status, format!("Saved {} {NOTE}", r["path"].as_str().unwrap()));
|
||||
// The Asset Export panel's export goes the same way.
|
||||
desktop.run("select.all", json!({})).unwrap();
|
||||
desktop.run("assets.add", json!({"multiple": false})).unwrap();
|
||||
crate::io::export_files(&mut desktop, "assets.export", json!({"folder": folder, "formats": rows})).unwrap();
|
||||
assert_eq!(desktop.ui.status, format!("Saved {folder} {NOTE}"), "assets.export");
|
||||
let (mut web, _) = app(1, true);
|
||||
place(&mut web);
|
||||
web.run("file.exportForScreens", json!({"zip": true, "formats": rows})).unwrap();
|
||||
assert_eq!(web.ui.status, format!("Saved Untitled-1.zip {NOTE}"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_export_reopens_on_its_rows() {
|
||||
let (mut app, _) = app(1, true);
|
||||
|
||||
@@ -628,6 +628,7 @@
|
||||
Created: Създадено:
|
||||
Crop Изрязване
|
||||
Crop Image Изрязване на изображение
|
||||
Edit Path Редактиране на контура
|
||||
Mask Маска
|
||||
Crop Marks Знаци за изрязване
|
||||
Crop To Изрязване до
|
||||
@@ -1320,6 +1321,9 @@
|
||||
Highlight Substituted Glyphs Маркиране на заменени глифи
|
||||
History История
|
||||
History Options… Опции за историята…
|
||||
Step Backward Стъпка назад
|
||||
Step Forward Стъпка напред
|
||||
Set History Limit… Задаване на ограничение на историята…
|
||||
History States Състояния на историята
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Прилагане на набор от данни
|
||||
@@ -4458,6 +4462,8 @@ lighting Light Светлина
|
||||
@msg missing panel layout липсващо оформление на панели
|
||||
@msg unsupported native docking operation неподдържана вградена операция за скачване
|
||||
Linux Display Server Дисплейен сървър за Linux
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) при свързана писалка
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Ако писалката на графичния таблет не рисува, изберете опция X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Начало на линийката
|
||||
Reset Ruler Origin Нулиране началото на линийката
|
||||
@@ -5132,6 +5138,11 @@ theme Appearance Външен вид
|
||||
Movie Clip Филмов клип
|
||||
@msg name is empty името е празно
|
||||
@msg no symbol named `{n}` няма символ с име `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one „Редактиране на оригинал“ отваря само файлове с изображения и документи; „{name}“ не е такъв файл
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” в браузъра се отварят само URL адреси с http, https и mailto, не „{url}“
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} отваря друга програма: само човекът пред клавиатурата може да изпълни командата
|
||||
@msg “{name}” is not a file „{name}“ не е файл
|
||||
Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране
|
||||
Persistent Постоянна
|
||||
Library Panel Options Опции на панела с библиотека
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -188,6 +188,7 @@
|
||||
Expand… Rozložit…
|
||||
Expand Appearance Rozložit vzhled
|
||||
Crop Image Oříznout obraz
|
||||
Edit Path Upravit cestu
|
||||
Mask Maska
|
||||
Rasterize… Rastrovat…
|
||||
Create Gradient Mesh… Vytvořit přechodovou síť…
|
||||
@@ -1513,6 +1514,9 @@ lighting Light Světlo
|
||||
Use Text for Previews Náhledy na textu
|
||||
{percent}% opacity krytí {percent} %
|
||||
History Options… Možnosti historie…
|
||||
Step Backward Krok zpět
|
||||
Step Forward Krok vpřed
|
||||
Set History Limit… Nastavit limit historie…
|
||||
Open a document to see its history. Otevřete dokument, abyste viděli jeho historii.
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Použít sadu dat
|
||||
@@ -3603,6 +3607,8 @@ axis Both Obě
|
||||
Star Hvězda
|
||||
Power Saving (integrated) Úsporný režim (integrovaná)
|
||||
Target Cíl
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) při připojeném peru
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Pokud pero grafického tabletu nekreslí, zvolte možnost X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Print as Bitmap Tisknout jako bitmapu
|
||||
Draft Koncept
|
||||
@@ -5150,6 +5156,11 @@ mark-style Japanese Japonské
|
||||
Movie Clip Filmový klip
|
||||
@msg name is empty název je prázdný
|
||||
@msg no symbol named `{n}` žádný symbol s názvem `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Upravit originál otevírá pouze soubory obrázků a dokumentů; „{name}“ mezi ně nepatří
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” v prohlížeči se otevírají pouze adresy URL http, https a mailto, ne „{url}“
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} otevírá jinou aplikaci: spustit ho může pouze člověk u klávesnice
|
||||
@msg “{name}” is not a file „{name}“ není soubor
|
||||
Enable Guides for 9-Slice Scaling Povolit vodítka pro 9-slice škálování
|
||||
@msg a mask has more than one art block maska má více než jeden blok kresby
|
||||
@msg mask art ends outside a mask dictionary kresba masky končí mimo slovník masky
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -173,6 +173,7 @@
|
||||
Expand… Udvid…
|
||||
Expand Appearance Udvid udseende
|
||||
Crop Image Beskær billede
|
||||
Edit Path Redigér kurve
|
||||
Mask Maskér
|
||||
Rasterize… Rasterisér…
|
||||
Create Gradient Mesh… Opret gradientnet…
|
||||
@@ -1434,6 +1435,9 @@ lighting Light Lys
|
||||
Use Text for Previews Brug tekst til forhåndsvisninger
|
||||
{percent}% opacity {percent}% opacitet
|
||||
History Options… Indstillinger for historik…
|
||||
Step Backward Et trin tilbage
|
||||
Step Forward Et trin frem
|
||||
Set History Limit… Angiv historikgrænse…
|
||||
Open a document to see its history. Åbn et dokument for at se dets historik.
|
||||
Abutting Stødende
|
||||
Black and White Sort-hvid
|
||||
@@ -3373,6 +3377,8 @@ axis Both Begge
|
||||
Star Stjerne
|
||||
Power Saving (integrated) Strømbesparende (integreret)
|
||||
Target Mål
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland), når en pen er tilsluttet
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Hvis pennen til en tegneplade ikke tegner, skal du vælge en X11 (XWayland)-indstilling.
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Print as Bitmap Udskriv som bitmap
|
||||
Draft Udkast
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -628,6 +628,7 @@
|
||||
Created: Erstellt:
|
||||
Crop Schnittmengenfläche
|
||||
Crop Image Bild beschneiden
|
||||
Edit Path Pfad bearbeiten
|
||||
Mask Maske
|
||||
Crop Marks Schnittmarken
|
||||
Crop To Beschneiden auf
|
||||
@@ -1338,6 +1339,9 @@ file-filter Templates Vorlagen
|
||||
Highlight Substituted Glyphs Ersetzte Glyphen hervorheben
|
||||
History Protokoll
|
||||
History Options… Protokolloptionen…
|
||||
Step Backward Schritt zurück
|
||||
Step Forward Schritt vorwärts
|
||||
Set History Limit… Protokollgrenze festlegen…
|
||||
History States Protokollschritte
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Datensatz anwenden
|
||||
@@ -4521,6 +4525,8 @@ lighting Light Licht
|
||||
@msg missing panel layout Bedienfeldanordnung fehlt
|
||||
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
|
||||
Linux Display Server Linux-Anzeigeserver
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland), wenn ein Stift angeschlossen ist
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Wenn der Stift eines Grafiktabletts nicht zeichnet, wählen Sie eine Option mit X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Linealursprung
|
||||
Reset Ruler Origin Linealursprung zurücksetzen
|
||||
@@ -4725,6 +4731,11 @@ effect Regular Regelmäßig
|
||||
Movie Clip Movieclip
|
||||
@msg name is empty Name ist leer
|
||||
@msg no symbol named `{n}` kein Symbol namens `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Original bearbeiten öffnet nur Bild- und Dokumentdateien; „{name}“ gehört nicht dazu
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” nur http-, https- und mailto-URLs werden im Browser geöffnet, nicht „{url}“
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} öffnet eine andere Anwendung: Nur die Person an der Tastatur kann den Befehl ausführen
|
||||
@msg “{name}” is not a file „{name}“ ist keine Datei
|
||||
Enable Guides for 9-Slice Scaling Hilfslinien für 9-Slice-Skalierung aktivieren
|
||||
@msg a mask has more than one art block eine Maske enthält mehr als einen Grafikblock
|
||||
@msg mask art ends outside a mask dictionary die Maskengrafik endet außerhalb eines Maskenwörterbuchs
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -602,6 +602,7 @@
|
||||
Created: Δημιουργήθηκε:
|
||||
Crop Περικοπή
|
||||
Crop Image Περικοπή εικόνας
|
||||
Edit Path Επεξεργασία διαδρομής
|
||||
Mask Μάσκα
|
||||
Crop Marks Σημάδια ξακρίσματος
|
||||
Crop To Περικοπή ως προς
|
||||
@@ -1283,6 +1284,9 @@
|
||||
Highlight Substituted Glyphs Επισήμανση υποκατεστημένων γλύφων
|
||||
History Ιστορικό
|
||||
History Options… Επιλογές ιστορικού…
|
||||
Step Backward Ένα βήμα πίσω
|
||||
Step Forward Ένα βήμα εμπρός
|
||||
Set History Limit… Ορισμός ορίου ιστορικού…
|
||||
History States Καταστάσεις ιστορικού
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Εφαρμογή συνόλου δεδομένων
|
||||
@@ -4947,6 +4951,8 @@ effect Points Σημεία
|
||||
Unicode Unicode
|
||||
Unicode (UTF-16) Unicode (UTF-16)
|
||||
Unicode (UTF-8) Unicode (UTF-8)
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) όταν είναι συνδεδεμένη πένα
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Αν η πένα μιας ταμπλέτας σχεδίασης δεν σχεδιάζει, επιλέξτε μία από τις ρυθμίσεις X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Mac Mac
|
||||
Phone 360×800 Phone 360×800
|
||||
@@ -5102,6 +5108,11 @@ effect Points Σημεία
|
||||
Movie Clip Κλιπ ταινίας
|
||||
@msg name is empty το όνομα είναι κενό
|
||||
@msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one η Επεξεργασία πρωτοτύπου ανοίγει μόνο αρχεία εικόνων και εγγράφων· το «{name}» δεν είναι τέτοιο αρχείο
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” στο πρόγραμμα περιήγησης ανοίγουν μόνο URL http, https και mailto, όχι το «{url}»
|
||||
@msg {id} opens another app: only the person at the keyboard can run it η εντολή {id} ανοίγει άλλη εφαρμογή: μόνο όποιος βρίσκεται στο πληκτρολόγιο μπορεί να την εκτελέσει
|
||||
@msg “{name}” is not a file το «{name}» δεν είναι αρχείο
|
||||
Enable Guides for 9-Slice Scaling Ενεργοποίηση οδηγών για κλιμάκωση 9-slice
|
||||
@msg a mask has more than one art block η μάσκα έχει περισσότερα από ένα μπλοκ γραφικών
|
||||
@msg mask art ends outside a mask dictionary τα γραφικά της μάσκας τελειώνουν έξω από το λεξικό της μάσκας
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2000 and column 19.
|
@@ -642,6 +642,7 @@
|
||||
Created: Creado:
|
||||
Crop Recortar
|
||||
Crop Image Recortar imagen
|
||||
Edit Path Editar trazado
|
||||
Mask Máscara
|
||||
Crop Marks Marcas de corte
|
||||
Crop To Recortar a
|
||||
@@ -1346,6 +1347,9 @@
|
||||
Highlight Substituted Glyphs Resaltar glifos sustituidos
|
||||
History Historial
|
||||
History Options… Opciones de historial…
|
||||
Step Backward Paso atrás
|
||||
Step Forward Paso adelante
|
||||
Set History Limit… Definir límite de historial…
|
||||
History States Estados de historial
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Aplicar conjunto de datos
|
||||
@@ -4525,6 +4529,8 @@ lighting Light Luz
|
||||
@msg missing panel layout Falta la distribución de paneles
|
||||
@msg unsupported native docking operation Operación nativa de anclaje no admitida
|
||||
Linux Display Server Servidor de pantalla de Linux
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) cuando hay un lápiz conectado
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Si el lápiz de una tableta digitalizadora no dibuja, elige una opción X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Origen de las reglas
|
||||
Reset Ruler Origin Restablecer el origen de las reglas
|
||||
@@ -5092,6 +5098,11 @@ mark-style Japanese Japonesas
|
||||
Movie Clip Clip de película
|
||||
@msg name is empty el nombre está vacío
|
||||
@msg no symbol named `{n}` no hay ningún símbolo llamado `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Editar original solo abre archivos de imagen y de documento; “{name}” no lo es
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” solo se abren en el navegador las URL http, https y mailto, no “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} abre otra aplicación: solo puede ejecutarlo la persona que está frente al teclado
|
||||
@msg “{name}” is not a file “{name}” no es un archivo
|
||||
Enable Guides for 9-Slice Scaling Activar guías para escalado de 9 sectores
|
||||
@msg a mask has more than one art block una máscara tiene más de un bloque de ilustración
|
||||
@msg mask art ends outside a mask dictionary la ilustración de la máscara termina fuera de un diccionario de máscara
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -195,6 +195,7 @@
|
||||
Expand… Laajenna…
|
||||
Expand Appearance Laajenna ulkoasu
|
||||
Crop Image Rajaa kuva
|
||||
Edit Path Muokkaa polkua
|
||||
Mask Maski
|
||||
Rasterize… Rasteroi…
|
||||
Create Gradient Mesh… Luo liukuväriverkko…
|
||||
@@ -1487,6 +1488,9 @@ lighting Light Valo
|
||||
Use Text for Previews Käytä tekstiä esikatseluissa
|
||||
{percent}% opacity {percent}% peittävyys
|
||||
History Options… Historian asetukset…
|
||||
Step Backward Askel taaksepäin
|
||||
Step Forward Askel eteenpäin
|
||||
Set History Limit… Aseta historian raja…
|
||||
Open a document to see its history. Avaa asiakirja, niin näet sen historian.
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Käytä tietojoukkoa
|
||||
@@ -3538,6 +3542,8 @@ axis Both Molemmat
|
||||
Star Tähti
|
||||
Power Saving (integrated) Virransäästö (integroitu)
|
||||
Target Kohde
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland), kun kynä on liitetty
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Jos piirtopöydän kynä ei piirrä, valitse jokin X11 (XWayland) -vaihtoehto.
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Print as Bitmap Tulosta bittikarttana
|
||||
Draft Vedos
|
||||
@@ -5114,6 +5120,11 @@ mark-style Japanese Japanilainen
|
||||
Movie Clip Elokuvaleike
|
||||
@msg name is empty nimi on tyhjä
|
||||
@msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Muokkaa alkuperäistä avaa vain kuva- ja asiakirjatiedostoja; “{name}” ei ole sellainen
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” selaimessa avataan vain http-, https- ja mailto-URL-osoitteet, ei “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} avaa toisen sovelluksen: vain näppäimistön ääressä oleva henkilö voi suorittaa sen
|
||||
@msg “{name}” is not a file “{name}” ei ole tiedosto
|
||||
Enable Guides for 9-Slice Scaling Ota käyttöön 9-slice-skaalauksen apuviivat
|
||||
@msg a mask has more than one art block maskissa on useampi kuin yksi grafiikkalohko
|
||||
@msg mask art ends outside a mask dictionary maskin grafiikka päättyy maskin sanakirjan ulkopuolelle
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -644,6 +644,7 @@
|
||||
Created: Création :
|
||||
Crop Recadrer
|
||||
Crop Image Recadrer l’image
|
||||
Edit Path Modifier le tracé
|
||||
Mask Masque
|
||||
Crop Marks Traits de coupe
|
||||
Crop To Recadrer selon
|
||||
@@ -1345,6 +1346,9 @@
|
||||
Highlight Substituted Glyphs Surligner les glyphes remplacés
|
||||
History Historique
|
||||
History Options… Options d’historique…
|
||||
Step Backward Pas en arrière
|
||||
Step Forward Pas en avant
|
||||
Set History Limit… Définir la limite de l’historique…
|
||||
History States États de l’historique
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Appliquer le jeu de données
|
||||
@@ -4535,6 +4539,8 @@ lighting Light Lumière
|
||||
@msg missing panel layout Disposition des panneaux manquante
|
||||
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
|
||||
Linux Display Server Serveur d’affichage Linux
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) quand un stylet est branché
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Si le stylet d’une tablette graphique ne dessine pas, choisissez une option X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Origine des règles
|
||||
Reset Ruler Origin Réinitialiser l’origine des règles
|
||||
@@ -5149,6 +5155,11 @@ theme Appearance Apparence
|
||||
Movie Clip Clip
|
||||
@msg name is empty le nom est vide
|
||||
@msg no symbol named `{n}` aucun symbole nommé `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Modifier l’original n’ouvre que les fichiers d’image et de document ; « {name} » n’en est pas un
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” seules les URL http, https et mailto s’ouvrent dans le navigateur, pas « {url} »
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} ouvre une autre application : seule la personne devant le clavier peut l’exécuter
|
||||
@msg “{name}” is not a file « {name} » n’est pas un fichier
|
||||
Enable Guides for 9-Slice Scaling Activer les repères pour la mise à l’échelle 9-slice
|
||||
@msg a mask has more than one art block un masque contient plusieurs blocs graphiques
|
||||
@msg mask art ends outside a mask dictionary les éléments graphiques du masque se terminent en dehors d’un dictionnaire de masque
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -840,6 +840,7 @@
|
||||
CRLF (Windows) CRLF (Windows)
|
||||
Crop Körülvágás
|
||||
Crop Image Kép körülvágása
|
||||
Edit Path Útvonal szerkesztése
|
||||
Crop Marks Vágójelek
|
||||
Crop To Körülvágás
|
||||
Cross Kereszt
|
||||
@@ -1719,6 +1720,9 @@ file-filter Templates Sablonok
|
||||
Hindi Hindi
|
||||
History Előzmények
|
||||
History Options… Előzmények beállításai…
|
||||
Step Backward Lépés vissza
|
||||
Step Forward Lépés előre
|
||||
Set History Limit… Előzménykorlát beállítása…
|
||||
History States Előzmények lépésszáma
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Adatkészlet alkalmazása
|
||||
@@ -4208,6 +4212,8 @@ mark-style Japanese Japán
|
||||
Wrinkle Tool Gyűrés
|
||||
Write Layers Rétegek írása
|
||||
x Height x-magasság
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland), ha toll csatlakozik
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Ha a grafikus tábla tolla nem rajzol, válassz egy X11 (XWayland) beállítást.
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
X: X:
|
||||
XML ID XML-azonosító
|
||||
@@ -5136,6 +5142,11 @@ imageTrace Outline Körvonal
|
||||
Movie Clip Mozgóklip
|
||||
@msg name is empty a név üres
|
||||
@msg no symbol named `{n}` nincs `{n}` nevű szimbólum
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one az Eredeti szerkesztése csak kép- és dokumentumfájlokat nyit meg; ez nem ilyen: „{name}”
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” a böngészőben csak a http-, https- és mailto-URL-ek nyílnak meg; ez nem ilyen: „{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id}: másik alkalmazást nyit meg, ezért csak a billentyűzetnél ülő felhasználó futtathatja
|
||||
@msg “{name}” is not a file nem fájl: „{name}”
|
||||
Enable Guides for 9-Slice Scaling Segédvonalak engedélyezése a 9-slice méretezéshez
|
||||
@msg a mask has more than one art block egy maszk egynél több grafikai blokkot tartalmaz
|
||||
@msg mask art ends outside a mask dictionary a maszk grafikája a maszk szótárán kívül ér véget
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -173,6 +173,7 @@
|
||||
Expand… Perluas…
|
||||
Expand Appearance Perluas tampilan
|
||||
Crop Image Pangkas gambar
|
||||
Edit Path Edit jalur
|
||||
Mask Masker
|
||||
Rasterize… Rasterisasi…
|
||||
Create Gradient Mesh… Buat mesh gradien…
|
||||
@@ -1434,6 +1435,9 @@ lighting Light Cahaya
|
||||
Use Text for Previews Gunakan teks untuk pratinjau
|
||||
{percent}% opacity Opasitas {percent}%
|
||||
History Options… Opsi riwayat…
|
||||
Step Backward Mundur satu langkah
|
||||
Step Forward Maju satu langkah
|
||||
Set History Limit… Atur batas riwayat…
|
||||
Open a document to see its history. Buka dokumen untuk melihat riwayatnya.
|
||||
Abutting Bersinggungan
|
||||
Black and White Hitam dan putih
|
||||
@@ -3373,6 +3377,8 @@ axis Both Keduanya
|
||||
Star Bintang
|
||||
Power Saving (integrated) Hemat daya (terintegrasi)
|
||||
Target Target
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) saat pena terpasang
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Jika pena tablet gambar tidak menggambar, pilih opsi X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Print as Bitmap Cetak sebagai bitmap
|
||||
Draft Draf
|
||||
|
||||
|
@@ -642,6 +642,7 @@
|
||||
Created: Creato:
|
||||
Crop Ritaglia
|
||||
Crop Image Ritaglia immagine
|
||||
Edit Path Modifica tracciato
|
||||
Mask Maschera
|
||||
Crop Marks Segni di ritaglio
|
||||
Crop To Ritaglia in base a
|
||||
@@ -1355,6 +1356,9 @@ file-filter Templates Modelli
|
||||
Highlight Substituted Glyphs Evidenzia glifi sostituiti
|
||||
History Cronologia
|
||||
History Options… Opzioni cronologia…
|
||||
Step Backward Passo indietro
|
||||
Step Forward Passo avanti
|
||||
Set History Limit… Imposta limite cronologia…
|
||||
History States Stati cronologia
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Applica set di dati
|
||||
@@ -4566,6 +4570,8 @@ lighting Light Luce
|
||||
@msg missing panel layout Disposizione dei pannelli mancante
|
||||
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
|
||||
Linux Display Server Server di visualizzazione Linux
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) quando è collegata una penna
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Se la penna di una tavoletta grafica non disegna, scegli un'opzione X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin Origine dei righelli
|
||||
Reset Ruler Origin Ripristina origine dei righelli
|
||||
@@ -4763,6 +4769,11 @@ lighting Light Luce
|
||||
Movie Clip Clip filmato
|
||||
@msg name is empty il nome è vuoto
|
||||
@msg no symbol named `{n}` nessun simbolo denominato `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Modifica originale apre solo file di immagini e documenti; “{name}” non lo è
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” nel browser si aprono solo URL http, https e mailto, non “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} apre un'altra applicazione: solo chi è alla tastiera può eseguirlo
|
||||
@msg “{name}” is not a file “{name}” non è un file
|
||||
Enable Guides for 9-Slice Scaling Abilita guide per il ridimensionamento 9-slice
|
||||
@msg a mask has more than one art block una maschera contiene più di un blocco grafico
|
||||
@msg mask art ends outside a mask dictionary la grafica della maschera termina fuori da un dizionario di maschera
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -188,6 +188,7 @@
|
||||
Expand… 分割・拡張…
|
||||
Expand Appearance アピアランスを分割
|
||||
Crop Image 画像の切り抜き
|
||||
Edit Path パスを編集
|
||||
Mask マスク
|
||||
Rasterize… ラスタライズ…
|
||||
Create Gradient Mesh… グラデーションメッシュを作成…
|
||||
@@ -1600,6 +1601,9 @@
|
||||
Highlight Substituted Glyphs 置換字形をハイライト
|
||||
Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト
|
||||
History Options… ヒストリーオプション…
|
||||
Step Backward 一段階戻る
|
||||
Step Forward 一段階進む
|
||||
Set History Limit… ヒストリーの上限を設定…
|
||||
History States ヒストリー数
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set データセットを適用
|
||||
@@ -4606,6 +4610,8 @@ lighting Light ライト
|
||||
@msg missing panel layout パネル配置がありません
|
||||
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
|
||||
Linux Display Server Linux ディスプレイサーバー
|
||||
X11 (XWayland) When a Pen Is Attached ペン接続時は X11 (XWayland)
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. ペンタブレットのペンで描画できない場合は、X11 (XWayland) のいずれかを選択してください。
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Ruler Origin 定規の原点
|
||||
Reset Ruler Origin 定規の原点をリセット
|
||||
@@ -4804,6 +4810,11 @@ lighting Light ライト
|
||||
Movie Clip ムービークリップ
|
||||
@msg name is empty 名前が空です
|
||||
@msg no symbol named `{n}` `{n}` という名前のシンボルはありません
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one 「オリジナルを編集」で開けるのは画像ファイルとドキュメントファイルだけです: 「{name}」はどちらでもありません
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” ブラウザーで開けるのは http、https、mailto の URL だけです: 「{url}」は開けません
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} は別のアプリを開きます: 実行できるのはキーボードの前にいるユーザーだけです
|
||||
@msg “{name}” is not a file 「{name}」はファイルではありません
|
||||
Enable Guides for 9-Slice Scaling 9スライス拡大・縮小用ガイドを有効にする
|
||||
@msg a mask has more than one art block マスクに複数のアートブロックがあります
|
||||
@msg mask art ends outside a mask dictionary マスクのアートがマスク辞書の外で終わっています
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -184,6 +184,7 @@
|
||||
Expand… 확장…
|
||||
Expand Appearance 모양새 확장
|
||||
Crop Image 이미지 자르기
|
||||
Edit Path 패스 편집
|
||||
Mask 마스크
|
||||
Rasterize… 래스터화…
|
||||
Create Gradient Mesh… 그레이디언트 메시 만들기…
|
||||
@@ -1595,6 +1596,9 @@
|
||||
Highlight Substituted Glyphs 대체된 글리프 강조 표시
|
||||
Highlight anchors on mouse over 마우스를 올리면 앵커 강조 표시
|
||||
History Options… 히스토리 옵션…
|
||||
Step Backward 한 단계 뒤로
|
||||
Step Forward 한 단계 앞으로
|
||||
Set History Limit… 히스토리 한도 설정…
|
||||
History States 히스토리 상태
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set 데이터 세트 적용
|
||||
@@ -4532,6 +4536,8 @@ lighting Light 광원
|
||||
@msg missing panel layout 패널 레이아웃이 없습니다
|
||||
@msg unsupported native docking operation 지원되지 않는 기본 도킹 작업
|
||||
Linux Display Server Linux 디스플레이 서버
|
||||
X11 (XWayland) When a Pen Is Attached 펜 연결 시 X11 (XWayland)
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. 드로잉 태블릿의 펜으로 그려지지 않으면 X11 (XWayland) 옵션을 선택하세요.
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Artistic 예술 효과
|
||||
Film Grain… 필름 그레인…
|
||||
@@ -4727,6 +4733,11 @@ lighting Light 광원
|
||||
Movie Clip 무비 클립
|
||||
@msg name is empty 이름이 비어 있습니다
|
||||
@msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one 원본 편집은 이미지 및 문서 파일만 엽니다. “{name}”은(는) 해당하지 않습니다
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” 브라우저에서는 http, https, mailto URL만 열립니다. “{url}”은(는) 열 수 없습니다
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id}은(는) 다른 앱을 엽니다. 키보드 앞에 있는 사용자만 실행할 수 있습니다
|
||||
@msg “{name}” is not a file “{name}”은(는) 파일이 아닙니다
|
||||
Enable Guides for 9-Slice Scaling 9-슬라이스 크기 조절용 안내선 사용
|
||||
@msg a mask has more than one art block 마스크에 아트 블록이 두 개 이상 있습니다
|
||||
@msg mask art ends outside a mask dictionary 마스크 아트가 마스크 사전 밖에서 끝납니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -187,6 +187,7 @@
|
||||
Expand… Uitbreiden…
|
||||
Expand Appearance Uiterlijk uitbreiden
|
||||
Crop Image Afbeelding bijsnijden
|
||||
Edit Path Pad bewerken
|
||||
Mask Masker
|
||||
Rasterize… Rasteriseren…
|
||||
Create Gradient Mesh… Verloopmesh maken…
|
||||
@@ -1448,6 +1449,9 @@ lighting Light Licht
|
||||
Use Text for Previews Tekst gebruiken voor voorbeelden
|
||||
{percent}% opacity {percent}% dekking
|
||||
History Options… Historieopties…
|
||||
Step Backward Stap terug
|
||||
Step Forward Stap vooruit
|
||||
Set History Limit… Historielimiet instellen…
|
||||
Open a document to see its history. Open een document om de historie te zien.
|
||||
Abutting Aaneengesloten
|
||||
Black and White Zwart-wit
|
||||
@@ -3387,6 +3391,8 @@ axis Both Beide
|
||||
Star Ster
|
||||
Power Saving (integrated) Energiebesparend (geïntegreerd)
|
||||
Target Doel
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland) als er een pen is aangesloten
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Als de pen van een tekentablet niet tekent, kies dan een X11 (XWayland)-optie.
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Print as Bitmap Afdrukken als bitmap
|
||||
Draft Concept
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -153,6 +153,7 @@
|
||||
Expand… Rozszerz…
|
||||
Expand Appearance Rozszerz wygląd
|
||||
Crop Image Kadruj obraz
|
||||
Edit Path Edytuj ścieżkę
|
||||
Mask Maska
|
||||
Rasterize… Rasteryzuj…
|
||||
Create Gradient Mesh… Utwórz siatkę gradientową…
|
||||
@@ -1427,6 +1428,9 @@ lighting Light Światło
|
||||
Use Text for Previews Użyj tekstu w podglądach
|
||||
{percent}% opacity Krycie {percent}%
|
||||
History Options… Opcje historii…
|
||||
Step Backward Krok wstecz
|
||||
Step Forward Krok naprzód
|
||||
Set History Limit… Ustaw limit historii…
|
||||
Open a document to see its history. Otwórz dokument, aby zobaczyć jego historię.
|
||||
# History panel: the names of undo steps (`every_undo_label_is_translated`).
|
||||
Apply Data Set Zastosuj zestaw danych
|
||||
@@ -3445,6 +3449,8 @@ axis Both Obie
|
||||
Star Gwiazda
|
||||
Power Saving (integrated) Oszczędzanie energii (zintegrowany)
|
||||
Target Cel
|
||||
X11 (XWayland) When a Pen Is Attached X11 (XWayland), gdy podłączone jest pióro
|
||||
If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Jeśli pióro tabletu graficznego nie rysuje, wybierz opcję X11 (XWayland).
|
||||
X11 (XWayland) X11 (XWayland)
|
||||
Print as Bitmap Drukuj jako bitmapę
|
||||
Draft Robocza
|
||||
@@ -5002,6 +5008,11 @@ mark-style Japanese Japońskie
|
||||
Movie Clip Klip filmowy
|
||||
@msg name is empty nazwa jest pusta
|
||||
@msg no symbol named `{n}` brak symbolu o nazwie `{n}`
|
||||
# Links and Attributes: files and URLs handed to other apps.
|
||||
@msg Edit Original opens image and document files only; “{name}” is not one Edytuj oryginał otwiera tylko pliki obrazów i dokumentów; „{name}” do nich nie należy
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” w przeglądarce otwierają się tylko adresy URL http, https i mailto, a nie „{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} otwiera inną aplikację: uruchomić to polecenie może tylko osoba przy klawiaturze
|
||||
@msg “{name}” is not a file „{name}” nie jest plikiem
|
||||
Enable Guides for 9-Slice Scaling Włącz prowadnice skalowania 9-slice
|
||||
@msg a mask has more than one art block maska ma więcej niż jeden blok grafiki
|
||||
@msg mask art ends outside a mask dictionary grafika maski kończy się poza słownikiem maski
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user