mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-18 05:07:02 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73f40e3fd3 | ||
|
|
049739b85e | ||
|
|
dcad0878c5 | ||
|
|
08c21446b4 | ||
|
|
d4a68dd73f | ||
|
|
45ca05e7b2 | ||
|
|
14d1667a7e | ||
|
|
5a2510576f | ||
|
|
47b5e886f8 | ||
|
|
650e938b68 | ||
|
|
36d751b72f | ||
|
|
c40718fafa | ||
|
|
d9d1a1aba3 | ||
|
|
2c4f059444 | ||
|
|
dba668240c | ||
|
|
a0657c9a22 | ||
|
|
5f20ef194b | ||
|
|
eb8eaa7aa0 | ||
|
|
1d6daaefcc | ||
|
|
78b7ffe1fb | ||
|
|
31df7aee9a | ||
|
|
2909972140 | ||
|
|
cfe77f1706 | ||
|
|
69dad83148 | ||
|
|
5646fa7253 | ||
|
|
545e935e0c | ||
|
|
46a1d9f175 | ||
|
|
f794069fbd | ||
|
|
c2bd5b4a52 | ||
|
|
2623e95a13 | ||
|
|
ced933af47 | ||
|
|
c29701f65b | ||
|
|
44e7be8b04 | ||
|
|
2c93a290e8 | ||
|
|
646a5cc9a3 | ||
|
|
20c8296b5a | ||
|
|
eb5bf7b828 | ||
|
|
36b85472d2 | ||
|
|
e69a08c1a6 | ||
|
|
602b3b34bc | ||
|
|
9361a3b3f1 | ||
|
|
dfb1a86a23 | ||
|
|
3f673c35eb | ||
|
|
2d2698e5fb | ||
|
|
0842941f8d | ||
|
|
df1bf3fe32 | ||
|
|
dbb6b470b3 | ||
|
|
9735518c84 | ||
|
|
cf5caa43d5 | ||
|
|
ed69938e0e | ||
|
|
ec410552cf | ||
|
|
a275496ef4 | ||
|
|
5851d8b1ad | ||
|
|
d8b0ce1013 | ||
|
|
282ed8955b | ||
|
|
034ef916ad | ||
|
|
138e9ca466 | ||
|
|
1caa957531 | ||
|
|
4803573940 | ||
|
|
3c0c112fbd | ||
|
|
aa9cf83fd2 | ||
|
|
24d4777d1a | ||
|
|
739b0b7335 | ||
|
|
468d7b21e9 | ||
|
|
db3092ae2e | ||
|
|
0cf12a1611 | ||
|
|
d23030ce2b | ||
|
|
2643f57b1f | ||
|
|
ef9e554dd2 | ||
|
|
438d27ffbe | ||
|
|
7d56260b47 | ||
|
|
22ce516e2d | ||
|
|
30e54dedcf | ||
|
|
e461b8b818 | ||
|
|
496288a023 | ||
|
|
bb7d6b487d | ||
|
|
dd5d421a9c | ||
|
|
5df664d1a7 | ||
|
|
b27b5b80ea | ||
|
|
2fc47948d5 | ||
|
|
df7daff3e4 | ||
|
|
030729d6f3 | ||
|
|
c0e6d8eb02 | ||
|
|
d7e79448d7 | ||
|
|
d66077a15f | ||
|
|
4eef9b7e8c | ||
|
|
aafe102649 | ||
|
|
ffc12babf2 | ||
|
|
830cefcaf6 | ||
|
|
bdf6059155 | ||
|
|
c73945d244 |
No files matched your search
Generated
+2
@@ -4536,6 +4536,7 @@ dependencies = [
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"webbrowser",
|
||||
@@ -4543,6 +4544,7 @@ dependencies = [
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-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-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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (Background Export progress dialog with Cancel, #2564; 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
|
||||
@@ -135,6 +135,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 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
|
||||
| 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. |
|
||||
@@ -183,6 +184,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
|
||||
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
|
||||
| 2026-10-17 | minor | Progress log row for the Background Export progress dialog (#2564); headline numbers unchanged. |
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
|
||||
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
|
||||
|
||||
@@ -78,8 +78,8 @@ USAGE:
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
@@ -88,6 +88,8 @@ USAGE:
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
|
||||
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
|
||||
precision (1–7, default 3). OUT may not be IN when reading
|
||||
@@ -259,6 +261,10 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
|
||||
let template = is_template(&output);
|
||||
if template && (artboard.is_some() || range.is_some()) {
|
||||
return Err("a .ait template holds every artboard: --artboard and --range don't apply".into());
|
||||
}
|
||||
let mut h = Headless::new();
|
||||
let opened = if editing_data {
|
||||
let params = json!({"path": input, "editingData": "always"});
|
||||
@@ -266,16 +272,22 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
} else {
|
||||
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
|
||||
};
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
let (command, mut params) = if template {
|
||||
// Save as Template writes every artboard, with the .ai options.
|
||||
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
|
||||
} else {
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
("document.export", params)
|
||||
};
|
||||
for (key, value) in legacy {
|
||||
params[key] = json!(value);
|
||||
}
|
||||
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
let mut r = h.call("engine.execute", json!({"command": command, "params": params})).map_err(|e| format!("export {output}: {e}"))?;
|
||||
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
|
||||
// much as what writing the output did.
|
||||
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
|
||||
@@ -285,6 +297,11 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `path` a `.ait` template? `file.saveAsTemplate` writes it; exports don't.
|
||||
fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
let file = args.first().ok_or("info needs a FILE")?;
|
||||
let mut h = Headless::new();
|
||||
@@ -371,7 +388,12 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
r
|
||||
}
|
||||
Step::Export(path) => {
|
||||
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", json!({"path": path, "scale": scale}))
|
||||
};
|
||||
let r = r.map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -179,3 +179,28 @@ fn convert_writes_legacy_ai_with_its_options() {
|
||||
assert!(!bad.status.success());
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
|
||||
/// convert and `run --export` write a `.ait` as Save as Template does: a PDF-compatible template of
|
||||
/// every artboard that opens as a new untitled document.
|
||||
#[test]
|
||||
fn convert_and_run_write_ait_templates() {
|
||||
let pdf = tmp("template-in.pdf");
|
||||
make(&pdf, 120, 80);
|
||||
let (converted, exported) = (tmp("converted.ait"), tmp("exported.ait"));
|
||||
let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &converted])).unwrap();
|
||||
assert_eq!((&r["format"], &r["path"]), (&Value::from("ait"), &Value::from(converted.as_str())), "{r}");
|
||||
let r: Value = serde_json::from_str(ok(&["run", "--in", &pdf, "--export", &exported]).lines().nth(1).unwrap()).unwrap();
|
||||
assert_eq!(r["result"]["format"], "ait", "{r}");
|
||||
for ait in [&converted, &exported] {
|
||||
assert!(std::fs::read(ait).unwrap().starts_with(b"%PDF"), "{ait}");
|
||||
let opened: Value = serde_json::from_str(ok(&["run", "--in", ait]).lines().next().unwrap()).unwrap();
|
||||
let opened = &opened["result"];
|
||||
assert_eq!((&opened["format"], &opened["restored"]), (&Value::from("ait"), &Value::from(true)), "{opened}");
|
||||
assert!(opened["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{opened}");
|
||||
assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}");
|
||||
}
|
||||
// A template holds every artboard: one can't be picked.
|
||||
let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
|
||||
assert!(!one.status.success());
|
||||
assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr));
|
||||
}
|
||||
@@ -75,7 +75,17 @@ pollster = "1"
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
|
||||
# so Automatic draws on that one. Both are already in the build through winit.
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
|
||||
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
|
||||
# through winit and rfd.
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
|
||||
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
|
||||
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
|
||||
# exactly: review the diff before bumping.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
|
||||
zbus = "=5.19.0"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
@@ -84,3 +94,5 @@ winresource = "0.1"
|
||||
[dev-dependencies]
|
||||
# Bound socket buffers in real loopback transport deadline regressions.
|
||||
socket2 = { workspace = true }
|
||||
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
|
||||
zbus = "=5.19.0"
|
||||
@@ -43,6 +43,8 @@ mod mac_screen_color;
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
|
||||
mod portal_save;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
@@ -359,10 +361,20 @@ fn make_folder(request: &PickRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
|
||||
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
|
||||
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
|
||||
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
|
||||
/// the portal can't.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
#[cfg_attr(windows, allow(unused_variables))]
|
||||
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
|
||||
make_folder(request);
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
if let PickRequest::Save(pick) = request
|
||||
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
|
||||
{
|
||||
return answer;
|
||||
}
|
||||
let paths = match request {
|
||||
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
|
||||
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
|
||||
@@ -375,7 +387,7 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
|
||||
/// Show the dialog for `request` over `parent` now → the paths picked.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
|
||||
show_dialog(file_dialog(&request, parent), &request)
|
||||
show_dialog(file_dialog(&request, parent), &request, parent)
|
||||
}
|
||||
|
||||
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
|
||||
@@ -431,11 +443,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ui = ui.clone();
|
||||
let parent = parent.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("file-dialog".into())
|
||||
.spawn(move || {
|
||||
// The app gone meanwhile has no use for the answer.
|
||||
let _ = tx.send(show_dialog(dialog, &request));
|
||||
let _ = tx.send(show_dialog(dialog, &request, &parent));
|
||||
ui.request_repaint();
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Linux and the BSDs: the save dialog of a save with two or more file types (Save, Save As, Save a
|
||||
//! Copy, Save as Template; #2679), asked of the XDG desktop portal here
|
||||
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html>).
|
||||
//! rfd 0.17 sends no `current_filter` with `SaveFile` and reads only `uris` from the answer
|
||||
//! (PolyMeilex/rfd#341), and the GTK file chooser behind the portal keeps the typed name when the
|
||||
//! user picks another file type, so the type picked was lost. This call sends the first file type as
|
||||
//! `current_filter`, reads the one picked back, and answers its first extension after the path
|
||||
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
|
||||
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
|
||||
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
|
||||
//!
|
||||
//! The dialog's parent is the window: `x11:<window id>` on X11, and on Wayland (Linux) a handle
|
||||
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_ui_egui::FilePick;
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
/// A file type in the dialog: its label and its extensions without the dot.
|
||||
type Filter = (String, Vec<String>);
|
||||
|
||||
/// A save dialog to show.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
struct SaveRequest {
|
||||
/// The portal's identifier of the parent window, or empty.
|
||||
parent: String,
|
||||
/// The suggested name; empty for none.
|
||||
name: String,
|
||||
folder: Option<String>,
|
||||
/// The file types, labeled in the interface's language; the dialog starts on the first.
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
impl SaveRequest {
|
||||
/// The dialog for `pick` over the window `parent` names.
|
||||
fn new(pick: &FilePick, parent: String) -> Self {
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let filters = pick
|
||||
.filters
|
||||
.iter()
|
||||
.map(|(label, exts)| {
|
||||
(vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", label).to_string(), exts.iter().map(|e| e.to_string()).collect())
|
||||
})
|
||||
.collect();
|
||||
Self { parent, name: pick.name.clone(), folder: pick.folder.clone(), filters }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the dialog answered: the path, as typed, and the label of the file type picked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Saved {
|
||||
path: String,
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
/// `filter` as the portal takes it: its label and a `*.ext` pattern for each extension (`*` for
|
||||
/// every file), as rfd sends them.
|
||||
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
|
||||
let globs = filter.1.iter().map(|e| (0, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
|
||||
(filter.0.clone(), globs)
|
||||
}
|
||||
|
||||
/// The `SaveFile` options for `request`, answering on `token`.
|
||||
fn options<'a>(request: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
|
||||
let mut o = HashMap::from([("handle_token", Value::from(token))]);
|
||||
if !request.name.is_empty() {
|
||||
o.insert("current_name", Value::from(request.name.as_str()));
|
||||
}
|
||||
if let Some(folder) = &request.folder {
|
||||
// A byte string ending in NUL, as the portal specifies.
|
||||
let mut bytes = folder.as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
o.insert("current_folder", Value::from(bytes));
|
||||
}
|
||||
if let Some(first) = request.filters.first() {
|
||||
o.insert("filters", Value::from(request.filters.iter().map(wire).collect::<Vec<_>>()));
|
||||
o.insert("current_filter", Value::from(wire(first)));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The portal's `Response`: code 0 with the first of its `uris` and the label of its
|
||||
/// `current_filter`. Any other code ends the dialog without a file (1 is Cancel, 2 a dialog closed
|
||||
/// some other way), as rfd reads it.
|
||||
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
|
||||
if code != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let uris: Vec<String> = results
|
||||
.get("uris")
|
||||
.ok_or("no file in the portal's answer")?
|
||||
.try_clone()
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_into()
|
||||
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
let uri = uris.first().ok_or("no file in the portal's answer")?;
|
||||
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
|
||||
/// or another scheme.
|
||||
fn file_uri_path(uri: &str) -> Option<String> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(rest.len());
|
||||
let mut rest = rest.as_bytes();
|
||||
while let Some((&b, tail)) = rest.split_first() {
|
||||
let hex = tail.get(..2).filter(|h| b == b'%' && h.iter().all(u8::is_ascii_hexdigit));
|
||||
match hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
Some(decoded) => {
|
||||
out.push(decoded);
|
||||
rest = tail.get(2..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
out.push(b);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
|
||||
Some(String::from_utf8_lossy(&out).into_owned())
|
||||
}
|
||||
|
||||
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
|
||||
/// the file type picked, found by its label. Empty when the dialog was canceled.
|
||||
fn pick_answer(saved: Option<Saved>, request: &SaveRequest) -> Vec<String> {
|
||||
let Some(saved) = saved else { return Vec::new() };
|
||||
let picked = saved.filter.and_then(|label| request.filters.iter().find(|(l, _)| *l == label)).and_then(|(_, exts)| exts.first().cloned());
|
||||
std::iter::once(saved.path).chain(picked).collect()
|
||||
}
|
||||
|
||||
/// The portal's identifier of an X11 window.
|
||||
fn x11_parent(window: impl std::fmt::LowerHex) -> String {
|
||||
format!("x11:{window:x}")
|
||||
}
|
||||
|
||||
/// Show the portal's save dialog for `pick` over `window` and wait for it → the answer (see the
|
||||
/// module's comment). `None` when `pick` offers fewer than two file types, or the portal couldn't
|
||||
/// show the dialog; rfd shows it then.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<Vec<String>> {
|
||||
if pick.filters.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
// The export stays until the dialog has answered: the handle is valid while it lives.
|
||||
let (parent, _export) = window.map(parent).unwrap_or_default();
|
||||
let request = SaveRequest::new(pick, parent);
|
||||
let saved = zbus::blocking::Connection::session().map_err(|e| e.to_string()).and_then(|conn| save_file(&conn, &request));
|
||||
match saved {
|
||||
Ok(saved) => Some(pick_answer(saved, &request)),
|
||||
Err(e) => {
|
||||
log::info!("the portal's save dialog failed ({e}): rfd shows it");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the portal on `conn` for the save dialog of `request` and wait for its answer.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use zbus::blocking::Proxy;
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
// Listen before asking, so that an answer that comes at once is not missed.
|
||||
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
|
||||
// The title is empty, as rfd sends it for a save.
|
||||
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
|
||||
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
|
||||
// returns.
|
||||
let reply = if handle.as_str() == path {
|
||||
replies.next()
|
||||
} else {
|
||||
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
|
||||
}
|
||||
.ok_or("the save dialog closed without an answer")?;
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results)
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window` when it is an X11 window.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn x11_window(window: &winit::window::Window) -> Option<String> {
|
||||
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
|
||||
match window.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Xlib(h) => Some(x11_parent(h.window)),
|
||||
RawWindowHandle::Xcb(h) => Some(x11_parent(h.window.get())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`, and on Wayland the export that keeps it valid; empty when
|
||||
/// the window has neither an X11 id nor a Wayland handle.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<wayland::Export>) {
|
||||
if let Some(id) = x11_window(window) {
|
||||
return (id, None);
|
||||
}
|
||||
match wayland::export(window) {
|
||||
Some(export) => (format!("wayland:{}", export.handle), Some(export)),
|
||||
None => (String::new(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off
|
||||
/// Linux).
|
||||
#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn parent(window: &winit::window::Window) -> (String, Option<()>) {
|
||||
(x11_window(window).unwrap_or_default(), None)
|
||||
}
|
||||
|
||||
/// The Wayland window's handle for the portal: an xdg-foreign v2 export of its surface
|
||||
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
|
||||
/// makes it.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland {
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::protocol::wl_surface::WlSurface;
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exported_v2::{self, ZxdgExportedV2};
|
||||
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exporter_v2::{self, ZxdgExporterV2};
|
||||
|
||||
/// A window exported for the portal: `handle` names it until this is dropped.
|
||||
pub struct Export {
|
||||
pub handle: String,
|
||||
exported: ZxdgExportedV2,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Drop for Export {
|
||||
fn drop(&mut self) {
|
||||
self.exported.destroy();
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// The events the export waits for.
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
handle: Option<String>,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, GlobalListContents> for State {
|
||||
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExporterV2, ()> for State {
|
||||
fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ZxdgExportedV2, ()> for State {
|
||||
fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
|
||||
if let zxdg_exported_v2::Event::Handle { handle } = event {
|
||||
state.handle = Some(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export `window`'s surface on the window's own Wayland connection, with an event queue of its
|
||||
/// own → `None` when the compositor has no xdg-foreign v2 or answers no handle.
|
||||
pub fn export(window: &winit::window::Window) -> Option<Export> {
|
||||
let (conn, surface) = surface(window)?;
|
||||
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
|
||||
let qh = queue.handle();
|
||||
let exporter: ZxdgExporterV2 = globals.bind(&qh, 1..=1, ()).ok()?;
|
||||
let exported = exporter.export_toplevel(&surface, &qh, ());
|
||||
let mut state = State::default();
|
||||
let answered = queue.roundtrip(&mut state);
|
||||
exporter.destroy();
|
||||
match (answered, state.handle) {
|
||||
(Ok(_), Some(handle)) => Some(Export { handle, exported, conn }),
|
||||
_ => {
|
||||
exported.destroy();
|
||||
let _ = conn.flush();
|
||||
log::info!("the compositor gave no xdg-foreign handle: the save dialog has no parent window");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `window`'s Wayland connection and surface, from its raw handles.
|
||||
#[allow(unsafe_code)]
|
||||
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
|
||||
use wayland_backend::sys::client::{Backend, ObjectId};
|
||||
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
|
||||
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
|
||||
(window.window_handle().ok()?.as_raw(), window.display_handle().ok()?.as_raw())
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
|
||||
// holds it until the export is dropped); a backend made from a foreign display does not
|
||||
// close it when dropped.
|
||||
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
|
||||
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), w.surface.as_ptr().cast()) }.ok()?;
|
||||
let surface = WlSurface::from_id(&conn, id).ok()?;
|
||||
Some((conn, surface))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request() -> SaveRequest {
|
||||
let pick = FilePick {
|
||||
name: "Untitled-1.vectorcraft".into(),
|
||||
folder: Some("/home/me/Art".into()),
|
||||
filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
|
||||
};
|
||||
SaveRequest::new(&pick, "x11:3a0000f".into())
|
||||
}
|
||||
|
||||
/// The dialog starts on the first file type (the document's format) and shows the suggested
|
||||
/// name in its folder.
|
||||
#[test]
|
||||
fn the_request_names_the_file_type_the_dialog_starts_on() {
|
||||
let request = request();
|
||||
let o = options(&request, "t1");
|
||||
assert_eq!(o.get("current_filter"), Some(&Value::from(("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]))));
|
||||
let filters = vec![
|
||||
("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]),
|
||||
("SVG".to_string(), vec![(0u32, "*.svg".to_string())]),
|
||||
("PDF-compatible .ai".to_string(), vec![(0u32, "*.ai".to_string())]),
|
||||
];
|
||||
assert_eq!(o.get("filters"), Some(&Value::from(filters)));
|
||||
assert_eq!(o.get("current_name"), Some(&Value::from("Untitled-1.vectorcraft")));
|
||||
assert_eq!(o.get("current_folder"), Some(&Value::from(b"/home/me/Art\0".to_vec())));
|
||||
assert_eq!(o.get("handle_token"), Some(&Value::from("t1")));
|
||||
let bare = SaveRequest { name: String::new(), folder: None, filters: Vec::new(), ..request };
|
||||
let o = options(&bare, "t2");
|
||||
assert_eq!(o.keys().copied().collect::<Vec<_>>(), ["handle_token"]);
|
||||
}
|
||||
|
||||
/// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`.
|
||||
fn results(uri: &str, filter: Option<&str>) -> HashMap<String, OwnedValue> {
|
||||
let mut r = HashMap::new();
|
||||
r.insert("uris".to_string(), OwnedValue::try_from(Value::from(vec![uri.to_string()])).unwrap());
|
||||
if let Some(label) = filter {
|
||||
let picked = Value::from((label.to_string(), vec![(0u32, "*.ai".to_string())]));
|
||||
r.insert("current_filter".to_string(), OwnedValue::try_from(picked).unwrap());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The file type picked comes back with the path as typed, and its first extension follows
|
||||
/// the path in the answer.
|
||||
#[test]
|
||||
fn the_answer_carries_the_file_type_picked() {
|
||||
let request = request();
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PDF-compatible .ai"))).unwrap();
|
||||
assert_eq!(saved, Some(Saved { path: "/home/me/Art/poster".into(), filter: Some("PDF-compatible .ai".into()) }));
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
// A portal that names no file type, or one the dialog did not offer: the path alone.
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster.svg", None)).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster.svg"]);
|
||||
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PNG"))).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
|
||||
}
|
||||
|
||||
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
|
||||
/// is an error, and rfd shows the dialog instead.
|
||||
#[test]
|
||||
fn a_canceled_dialog_answers_no_file() {
|
||||
let request = request();
|
||||
for code in [1, 2] {
|
||||
assert_eq!(answer(code, &HashMap::new()), Ok(None));
|
||||
assert!(pick_answer(None, &request).is_empty());
|
||||
}
|
||||
assert!(answer(0, &HashMap::new()).is_err());
|
||||
assert!(answer(0, &results("sftp://host/poster.svg", Some("SVG"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
|
||||
// A `%` without two hex digits after it stays as it is.
|
||||
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
|
||||
assert_eq!(file_uri_path("file://host/x.svg"), None);
|
||||
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_by_their_id() {
|
||||
assert_eq!(x11_parent(0x3a0000f), "x11:3a0000f");
|
||||
}
|
||||
|
||||
/// What the mock portal answers to each `SaveFile` call: the response code, and the file and
|
||||
/// the label of the file type "picked".
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
|
||||
|
||||
/// What each `SaveFile` call carried: `current_name` and the label of `current_filter`.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
|
||||
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
|
||||
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
|
||||
/// is no `dbus-daemon` to run.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
let mut daemon = std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server =
|
||||
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
|
||||
let seen = Seen::default();
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
continue;
|
||||
}
|
||||
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
|
||||
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
|
||||
let filter = options.get("current_filter").and_then(|v| {
|
||||
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
let calls = log.lock().map(|mut l| {
|
||||
l.push((text("current_name"), filter));
|
||||
l.len()
|
||||
});
|
||||
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
|
||||
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
if own_paths {
|
||||
// As a person answers the dialog: after the caller listened on the path returned.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
let (code, pick) = script.next().unwrap_or((2, None));
|
||||
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
if let Some((uri, label)) = pick {
|
||||
results.insert("uris", Value::from(vec![uri.to_string()]));
|
||||
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
|
||||
}
|
||||
let to = header.sender().cloned().map(zbus::names::BusName::from);
|
||||
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
|
||||
Some((client, seen, daemon))
|
||||
}
|
||||
|
||||
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
|
||||
/// name, and the answer names the file and the type picked; Cancel answers no file.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
|
||||
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
let saved = save_file(&conn, &request).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
assert_eq!(save_file(&conn, &request), Ok(None));
|
||||
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
|
||||
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
|
||||
/// there.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
|
||||
#[test]
|
||||
fn the_file_types_are_labeled_as_the_other_dialogs_label_them() {
|
||||
let request = request();
|
||||
let labels: Vec<&str> = request.filters.iter().map(|(l, _)| l.as_str()).collect();
|
||||
let lang = vectorcraft_ui_egui::i18n::current();
|
||||
let expected: Vec<&str> =
|
||||
["VectorCraft", "SVG", "PDF-compatible .ai"].iter().map(|l| vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", l)).collect();
|
||||
assert_eq!(labels, expected);
|
||||
assert_eq!(request.parent, "x11:3a0000f");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cms::{self, Model};
|
||||
use crate::{Color, Gradient, GradientStop};
|
||||
|
||||
/// The spread a new point gets.
|
||||
@@ -157,11 +158,12 @@ impl Freeform {
|
||||
f
|
||||
}
|
||||
|
||||
/// Colour the points along `g`, first to last.
|
||||
/// Color the points along `g`, first to last, mixing neighboring stops in the model they share
|
||||
/// ([`Color::lerp_keep_model`]).
|
||||
pub fn recolor(&mut self, g: &Gradient) {
|
||||
let n = self.points.len();
|
||||
for (k, p) in self.points.iter_mut().enumerate() {
|
||||
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
|
||||
(p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +232,10 @@ impl Freeform {
|
||||
best
|
||||
}
|
||||
|
||||
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
|
||||
/// The color and opacity at `p` for a painted box whose [`spread_scale`] is `scale`, as
|
||||
/// [`Self::model_field`] mixes them.
|
||||
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
|
||||
let ([r, g, b], a) = self.field(scale).sample(p);
|
||||
(Color::rgb(r, g, b), a)
|
||||
self.model_field(scale).sample(p)
|
||||
}
|
||||
|
||||
/// The colour field, with colours converted and lines tessellated once (for sampling many
|
||||
@@ -265,6 +267,32 @@ impl Freeform {
|
||||
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
|
||||
}
|
||||
|
||||
/// [`Self::field`] mixing the colors in the model every point shares instead of display RGB:
|
||||
/// the C, M, Y and K inks (as a CMYK document drawn by ink planes mixes them), the Gray ink,
|
||||
/// or the RGB or Lab components. Points in different models mix in display RGB, as
|
||||
/// [`Self::field`] mixes them, and the field gives that color in the working RGB space.
|
||||
pub fn model_field(&self, scale: f64) -> ModelField {
|
||||
let Some(model) = self.shared_model() else { return ModelField { model: None, first: self.field(scale), k: None } };
|
||||
let parts = |c: &Color| match *c {
|
||||
Color::Rgb { r, g, b } => [r, g, b, 0.0],
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
|
||||
Color::Lab { l, a, b } => [l, a, b, 0.0],
|
||||
};
|
||||
let first = self.field_with(scale, &|c| {
|
||||
let [x, y, z, _] = parts(c);
|
||||
[x, y, z]
|
||||
});
|
||||
let k = (model == Model::Cmyk).then(|| self.field_with(scale, &|c| [parts(c)[3], 0.0, 0.0]));
|
||||
ModelField { model: Some(model), first, k }
|
||||
}
|
||||
|
||||
/// The color model every point is in (`None` without points or when their models differ).
|
||||
pub fn shared_model(&self) -> Option<Model> {
|
||||
let m = self.points.first()?.color.model();
|
||||
self.points.iter().all(|p| p.color.model() == m).then_some(m)
|
||||
}
|
||||
|
||||
/// Add a point; returns its index.
|
||||
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
|
||||
self.points.push(p);
|
||||
@@ -324,8 +352,9 @@ impl Freeform {
|
||||
true
|
||||
}
|
||||
|
||||
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
|
||||
/// opacity and spread between the segment's ends. Returns the new point's index.
|
||||
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the color
|
||||
/// (in the model the ends share, [`Color::lerp_keep_model`]), opacity and spread between the
|
||||
/// segment's ends. Returns the new point's index.
|
||||
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
|
||||
let c = *self.segments(line).get(segment)?;
|
||||
let l = &self.lines[line];
|
||||
@@ -334,7 +363,7 @@ impl Freeform {
|
||||
let u = t as f32;
|
||||
let p = FreeformPoint {
|
||||
at: c.eval(t),
|
||||
color: a.color.lerp(&b.color, u),
|
||||
color: a.color.lerp_keep_model(&b.color, u),
|
||||
opacity: a.opacity + (b.opacity - a.opacity) * u,
|
||||
spread: a.spread + (b.spread - a.spread) * u,
|
||||
};
|
||||
@@ -440,6 +469,41 @@ impl Field {
|
||||
}
|
||||
}
|
||||
|
||||
/// A freeform gradient ready to sample in its points' color model ([`Freeform::model_field`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelField {
|
||||
/// The model the points share (`None` when their models differ).
|
||||
model: Option<Model>,
|
||||
/// The first three components (C, M and Y; R, G and B; L, a and b; display RGB when the
|
||||
/// models differ), or the Gray ink as the first of the three values.
|
||||
first: Field,
|
||||
/// The K ink of a CMYK field.
|
||||
k: Option<Field>,
|
||||
}
|
||||
|
||||
impl ModelField {
|
||||
/// The model the points share (`None` when their models differ).
|
||||
pub fn model(&self) -> Option<Model> {
|
||||
self.model
|
||||
}
|
||||
|
||||
/// The color and opacity at `p`: in the points' shared model, else in the working RGB space.
|
||||
pub fn sample(&self, p: Point) -> (Color, f32) {
|
||||
let ([x, y, z], a) = self.first.sample(p);
|
||||
let c = match self.model {
|
||||
None => {
|
||||
let [r, g, b] = cms::active().srgb_to_rgb([x, y, z]);
|
||||
Color::rgb(r, g, b)
|
||||
}
|
||||
Some(Model::Rgb) => Color::rgb(x, y, z),
|
||||
Some(Model::Cmyk) => Color::cmyk(x, y, z, self.k.as_ref().map_or(0.0, |k| k.sample(p).0[0])),
|
||||
Some(Model::Gray) => Color::gray(x),
|
||||
Some(Model::Lab) => Color::lab(x, y, z),
|
||||
};
|
||||
(c, a)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -477,6 +541,31 @@ mod tests {
|
||||
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_mixes_in_the_model_the_points_share() {
|
||||
let halfway = |a: Color, b: Color| {
|
||||
let mut f = two(0.0);
|
||||
(f.points[0].color, f.points[1].color) = (a, b);
|
||||
f.sample(Point::new(50.0, 0.0), 50.0).0
|
||||
};
|
||||
let near = |a: Color, b: Color| match (a, b) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => {
|
||||
[c - c2, m - m2, y - y2, k - k2].iter().all(|d| d.abs() < 1e-5)
|
||||
}
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() < 1e-5,
|
||||
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|d| d.abs() < 1e-4),
|
||||
_ => false,
|
||||
};
|
||||
let c = halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
|
||||
assert!(near(c, Color::cmyk(0.5, 0.0, 0.0, 0.5)), "{c:?}");
|
||||
let c = halfway(Color::gray(0.2), Color::gray(0.6));
|
||||
assert!(near(c, Color::gray(0.4)), "{c:?}");
|
||||
let c = halfway(Color::lab(50.0, 10.0, -20.0), Color::lab(70.0, 30.0, 0.0));
|
||||
assert!(near(c, Color::lab(60.0, 20.0, -10.0)), "{c:?}");
|
||||
// Points in different models mix in RGB.
|
||||
assert!(matches!(halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), red()), Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
|
||||
// Scale 100: a 0.2 spread is a 20 pt disc.
|
||||
|
||||
@@ -241,6 +241,17 @@ impl Color {
|
||||
let b = other.to_rgb();
|
||||
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
|
||||
}
|
||||
/// Linear interpolation in the model both colors share when it is CMYK, Gray or Lab, else in
|
||||
/// display RGB ([`Color::lerp`]).
|
||||
pub fn lerp_keep_model(&self, other: &Color, t: f32) -> Color {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
match (*self, *other) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
|
||||
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
|
||||
_ => self.lerp(other, t),
|
||||
}
|
||||
}
|
||||
/// The colour model this colour is authored in.
|
||||
pub fn model(&self) -> cms::Model {
|
||||
match self {
|
||||
|
||||
@@ -33,13 +33,7 @@ use crate::text::TextObject;
|
||||
|
||||
/// Interpolate two colours in their shared model (display RGB when the models differ).
|
||||
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
|
||||
let l = |x: f32, y: f32| x + (y - x) * t;
|
||||
match (*a, *b) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
|
||||
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
|
||||
_ => a.lerp(b, t),
|
||||
}
|
||||
a.lerp_keep_model(b, t)
|
||||
}
|
||||
|
||||
fn lerp_f32(x: f32, y: f32, t: f32) -> f32 {
|
||||
|
||||
+92
-3
@@ -541,13 +541,16 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
out
|
||||
}
|
||||
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
|
||||
/// ([`shaper_hides`]).
|
||||
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
|
||||
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
|
||||
/// compound paths, layers and groups are touched by their box.
|
||||
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)
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { 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)),
|
||||
NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, 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| {
|
||||
@@ -558,7 +561,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}
|
||||
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, .. } => {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
|
||||
let Some(r) = marquee_in(n, r) else { return };
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
|
||||
@@ -822,6 +825,92 @@ mod tests {
|
||||
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
|
||||
}
|
||||
|
||||
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
|
||||
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
|
||||
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
|
||||
/// Selection take, or in isolation mode, it selects the member.
|
||||
#[test]
|
||||
fn a_marquee_meets_an_intertwine_group_by_its_members() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||||
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
|
||||
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
|
||||
let (a_id, g) = (a.id, d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
|
||||
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
|
||||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
|
||||
assert!(marquee(&d, gap, None, false).is_empty());
|
||||
assert!(marquee(&d, gap, None, true).is_empty());
|
||||
assert!(marquee(&d, gap, Some(g), false).is_empty());
|
||||
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
|
||||
// This marquee crosses the first square's left edge.
|
||||
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||||
assert_eq!(marquee(&d, edge, None, false), vec![g]);
|
||||
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
|
||||
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
|
||||
}
|
||||
|
||||
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
|
||||
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
|
||||
#[test]
|
||||
fn a_marquee_meets_a_repeat_by_its_copies() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
|
||||
// Copies at 0..20, 40..60 and 80..100.
|
||||
let kind = crate::pattern::RepeatKind::Grid {
|
||||
h_spacing: 20.0,
|
||||
v_spacing: 20.0,
|
||||
rows: 1,
|
||||
cols: 3,
|
||||
grid_type: crate::pattern::TileType::Grid,
|
||||
flip_rows: false,
|
||||
flip_cols: false,
|
||||
};
|
||||
let r = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
|
||||
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
|
||||
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
|
||||
assert!(marquee(&d, gap, None, false).is_empty());
|
||||
assert!(marquee(&d, gap, None, true).is_empty());
|
||||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
|
||||
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
|
||||
}
|
||||
|
||||
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
|
||||
/// too: inside the box, away from every key object's and the content's outline, it takes them.
|
||||
#[test]
|
||||
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let l = d.layers[0].id;
|
||||
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
|
||||
let blend = d.alloc_id();
|
||||
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
|
||||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
|
||||
let envelope = d.alloc_id();
|
||||
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
|
||||
let e = Node::new(
|
||||
envelope,
|
||||
NodeKind::Envelope {
|
||||
content,
|
||||
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
|
||||
fidelity: 100.0,
|
||||
editing: false,
|
||||
options: Default::default(),
|
||||
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
|
||||
},
|
||||
);
|
||||
d.insert(Some(l), 1, e).unwrap();
|
||||
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
|
||||
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
|
||||
assert_eq!(marquee(&d, r, None, false), vec![id]);
|
||||
assert_eq!(marquee(&d, r, None, true), vec![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]
|
||||
|
||||
@@ -117,9 +117,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
|
||||
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
|
||||
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics,
|
||||
SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
|
||||
+38
-7
@@ -730,19 +730,35 @@ impl GradientMesh {
|
||||
.to_point()
|
||||
}
|
||||
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v).
|
||||
/// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all
|
||||
/// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out
|
||||
/// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB.
|
||||
pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) {
|
||||
let q =
|
||||
[&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]];
|
||||
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
|
||||
let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum();
|
||||
let cmyk: Option<Vec<[f32; 4]>> =
|
||||
q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect();
|
||||
if let Some(inks) = cmyk {
|
||||
let mut mix = [0.0f32; 4];
|
||||
for (ink, w) in inks.iter().zip(w) {
|
||||
for (m, x) in mix.iter_mut().zip(ink) {
|
||||
*m += x * w as f32;
|
||||
}
|
||||
}
|
||||
let [c, m, y, k] = mix;
|
||||
return (Color::Cmyk { c, m, y, k }, op);
|
||||
}
|
||||
let grey: Option<Vec<f32>> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect();
|
||||
if let Some(ks) = grey {
|
||||
return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op);
|
||||
}
|
||||
let mut rgb = [0.0f32; 3];
|
||||
let mut op = 0.0f32;
|
||||
for k in 0..4 {
|
||||
let c = q[k].color.to_rgb();
|
||||
for (ch, x) in rgb.iter_mut().zip(c) {
|
||||
*ch += x * w[k] as f32;
|
||||
for (p, w) in q.iter().zip(w) {
|
||||
for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) {
|
||||
*ch += x * w as f32;
|
||||
}
|
||||
op += q[k].opacity * w[k] as f32;
|
||||
}
|
||||
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
||||
}
|
||||
@@ -1823,4 +1839,19 @@ mod tests {
|
||||
assert!(m.remove_point_lines(i));
|
||||
assert_eq!((m.rows, m.cols), (1, 1));
|
||||
}
|
||||
|
||||
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
|
||||
#[test]
|
||||
fn a_cmyk_mesh_mixes_its_inks() {
|
||||
let cyan = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
|
||||
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, cyan, MeshAppearance::Flat, 0.0).unwrap();
|
||||
m.points[0].color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
|
||||
let (c, op) = m.color_at(0, 0, 0.0, 0.0);
|
||||
assert_eq!((c, op), (Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }, 1.0));
|
||||
let Color::Cmyk { c, k, .. } = m.color_at(0, 0, 0.5, 0.5).0 else { panic!("mixed out of CMYK") };
|
||||
assert!((c - 0.75).abs() < 1e-6 && (k - 0.25).abs() < 1e-6, "{c} {k}");
|
||||
// A corner in another space mixes them all in RGB.
|
||||
m.points[1].color = Color::rgb(1.0, 0.0, 0.0);
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
|
||||
}
|
||||
}
|
||||
@@ -506,6 +506,98 @@ impl Default for HyphenationOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SingleWordJustify {
|
||||
/// Spread across the line, between its letters.
|
||||
#[default]
|
||||
FullJustify,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
}
|
||||
|
||||
/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent.
|
||||
///
|
||||
/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired
|
||||
/// applies to every line of the paragraph, justified or not: word spaces are that share of the
|
||||
/// font's word space, each letter is followed by that share of a word space, and glyphs are that
|
||||
/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only
|
||||
/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph
|
||||
/// scaling 100/100/100, Auto Leading 120 and Full Justify.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct JustificationOptions {
|
||||
/// Word Spacing, percent of the font's word space (0..1000).
|
||||
pub word_spacing: [f64; 3],
|
||||
/// Letter Spacing, percent of the font's word space added after each letter (-100..500).
|
||||
pub letter_spacing: [f64; 3],
|
||||
/// Glyph Scaling, percent of the glyphs' width (50..200).
|
||||
pub glyph_scaling: [f64; 3],
|
||||
/// Auto Leading: the leading of characters whose leading is Auto, in percent of their size
|
||||
/// (0..500).
|
||||
pub auto_leading: f64,
|
||||
/// Single Word Justification: how a justified line holding one word is set.
|
||||
pub single_word: SingleWordJustify,
|
||||
}
|
||||
|
||||
impl JustificationOptions {
|
||||
/// Word Spacing's range (percent).
|
||||
pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0);
|
||||
/// Letter Spacing's range (percent).
|
||||
pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0);
|
||||
/// Glyph Scaling's range (percent).
|
||||
pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0);
|
||||
/// Auto Leading's range (percent).
|
||||
pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0);
|
||||
|
||||
/// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change
|
||||
/// in a line's natural widths)?
|
||||
pub fn desired_is_default(&self) -> bool {
|
||||
let d = Self::default();
|
||||
self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1]
|
||||
}
|
||||
|
||||
/// Keeps options read from a file to what `text.setFormat` can set: a value that is not a
|
||||
/// finite number becomes its default, every value is clamped to its range, and a Minimum,
|
||||
/// Desired and Maximum out of order go back to their defaults together.
|
||||
pub fn keep_in_range(&mut self) {
|
||||
let d = Self::default();
|
||||
for (v, dv, (lo, hi)) in [
|
||||
(&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING),
|
||||
(&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING),
|
||||
(&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING),
|
||||
] {
|
||||
for (x, dx) in v.iter_mut().zip(dv) {
|
||||
*x = if x.is_finite() { x.clamp(lo, hi) } else { dx };
|
||||
}
|
||||
if !(v[0] <= v[1] && v[1] <= v[2]) {
|
||||
*v = dv;
|
||||
}
|
||||
}
|
||||
let (lo, hi) = Self::AUTO_LEADING;
|
||||
self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading };
|
||||
}
|
||||
|
||||
/// A paragraph's leading for characters of `size` whose leading is Auto.
|
||||
pub fn auto_leading_for(&self, size: f64) -> f64 {
|
||||
size * self.auto_leading / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JustificationOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
word_spacing: [80.0, 100.0, 133.0],
|
||||
letter_spacing: [0.0, 0.0, 0.0],
|
||||
glyph_scaling: [100.0, 100.0, 100.0],
|
||||
auto_leading: 120.0,
|
||||
single_word: SingleWordJustify::FullJustify,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -522,6 +614,9 @@ pub struct ParaStyle {
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification
|
||||
/// (Paragraph panel menu › Justification…).
|
||||
pub justification: JustificationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -591,6 +686,8 @@ struct ParaStyleFile {
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
justification: JustificationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -627,6 +724,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -648,6 +746,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -664,6 +763,8 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
impl From<ParaStyleFile> for ParaStyle {
|
||||
fn from(f: ParaStyleFile) -> Self {
|
||||
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
|
||||
let mut justification = f.justification;
|
||||
justification.keep_in_range();
|
||||
let ParaStyleFile {
|
||||
left_indent,
|
||||
right_indent,
|
||||
@@ -692,6 +793,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -1585,6 +1687,37 @@ mod tests {
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
|
||||
}
|
||||
|
||||
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
|
||||
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
|
||||
/// Maximum out of order go back to their defaults.
|
||||
#[test]
|
||||
fn justification_options_read_from_a_file_stay_in_range() {
|
||||
let d = JustificationOptions::default();
|
||||
let read: ParaStyle = serde_json::from_value(serde_json::json!({
|
||||
"justification": {
|
||||
"word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0],
|
||||
"glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let j = read.justification;
|
||||
assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]);
|
||||
assert_eq!(j.letter_spacing, d.letter_spacing, "out of order");
|
||||
assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]);
|
||||
assert_eq!(j.auto_leading, 0.0);
|
||||
let mut j = JustificationOptions {
|
||||
word_spacing: [f64::NAN, 100.0, f64::INFINITY],
|
||||
letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY],
|
||||
auto_leading: f64::NAN,
|
||||
..d.clone()
|
||||
};
|
||||
j.keep_in_range();
|
||||
assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading));
|
||||
let mut same = d.clone();
|
||||
same.keep_in_range();
|
||||
assert_eq!(same, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
|
||||
@@ -48,15 +48,29 @@ enum Channel {
|
||||
|
||||
impl Channel {
|
||||
fn of(p: &Value) -> Self {
|
||||
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
|
||||
"red" | "r" => Channel::One(0),
|
||||
"green" | "g" => Channel::One(1),
|
||||
"blue" | "b" => Channel::One(2),
|
||||
_ => Channel::All,
|
||||
Self::parse(text(p, "channel", "rgb")).unwrap_or(Channel::All)
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"rgb" => Some(Channel::All),
|
||||
"red" | "r" => Some(Channel::One(0)),
|
||||
"green" | "g" => Some(Channel::One(1)),
|
||||
"blue" | "b" => Some(Channel::One(2)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The channels Curves and Levels apply to (`channel`), as their parameters list them.
|
||||
pub const CHANNELS: [&str; 4] = ["rgb", "red", "green", "blue"];
|
||||
|
||||
/// Do Curves and Levels read `s` as their `channel`: one of [`CHANNELS`], or r, g or b, in any
|
||||
/// case? They draw any other value on all three channels, as RGB.
|
||||
pub fn is_channel(s: &str) -> bool {
|
||||
Channel::parse(s).is_some()
|
||||
}
|
||||
|
||||
/// Entries of a sampled tone curve.
|
||||
const LUT: usize = 256;
|
||||
|
||||
|
||||
@@ -59,7 +59,10 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
|
||||
pub use adjust::{
|
||||
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
|
||||
is_channel,
|
||||
};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
|
||||
@@ -210,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Symbolism Tool",
|
||||
[],
|
||||
None,
|
||||
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, style?: name} adjust instances near the points (selection, else all) → {count}",
|
||||
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}",
|
||||
has_doc,
|
||||
symbol_adjust
|
||||
),
|
||||
@@ -1110,7 +1110,7 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let alt = bool_or(p, "alt", false);
|
||||
let st = s.doc()?;
|
||||
let color = match p.get("color").and_then(color_value) {
|
||||
Some(c) => c,
|
||||
Some(c) => super::paint::new_color(s, p, c),
|
||||
None => s.paint.fill.color().unwrap_or(Color::BLACK),
|
||||
};
|
||||
let style_paint = match str_param(p, "style") {
|
||||
|
||||
@@ -232,7 +232,7 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
|
||||
Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => {
|
||||
paint_from(s, v)
|
||||
}
|
||||
Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
|
||||
Some(v) => color_value(v).map(|c| Some(Paint::solid(super::paint::new_color(s, p, c)))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -142,7 +142,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "colorTheme.addToSwatches";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let t = &s.prefs.color_themes[find(s, name, C)?];
|
||||
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>() });
|
||||
// The theme's colors keep their model, in a CMYK document too.
|
||||
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>(), "keepModel": true });
|
||||
let r = s.execute("swatch.newGroup", ¶ms)?;
|
||||
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
|
||||
}
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -573,8 +573,9 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraf
|
||||
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
|
||||
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
|
||||
/// filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
let mut style = CharStyle::default();
|
||||
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
|
||||
@@ -587,7 +588,7 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
style.font_style = f.to_string();
|
||||
}
|
||||
style.fill = match p.get("color").and_then(color_value) {
|
||||
Some(c) => vectorcraft_color::Paint::solid(c),
|
||||
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
|
||||
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
|
||||
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
|
||||
};
|
||||
|
||||
@@ -75,6 +75,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
||||
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
||||
cmd!(
|
||||
"edit.clearHistory",
|
||||
"Clear History",
|
||||
[],
|
||||
None,
|
||||
"{} remove every undo and redo step of the active document (the History panel menu's Clear History): the document stays as it is, and clearing is not an undo step → {cleared: the number of steps removed}",
|
||||
can_clear_history,
|
||||
clear_history
|
||||
),
|
||||
cmd!(
|
||||
"edit.undoGroup.begin",
|
||||
"Begin Undo Group",
|
||||
@@ -380,7 +389,9 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.redo.last());
|
||||
st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
|
||||
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
|
||||
turned.named = e.named;
|
||||
st.history.redo.push(turned);
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
@@ -403,7 +414,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.undo.last());
|
||||
st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
|
||||
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
|
||||
turned.named = e.named;
|
||||
st.history.undo.push(turned);
|
||||
st.history.trim();
|
||||
st.revision += 1;
|
||||
st.selection.prune(&st.doc);
|
||||
@@ -414,6 +427,19 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
|
||||
/// History panel › Clear History: removes the steps after the current state and the steps before
|
||||
/// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a
|
||||
/// step, as Undo keeps it, so it is removed with the others and its change stays in the document.
|
||||
fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
let cleared = st.history.undo.len() + st.history.redo.len();
|
||||
st.history.undo.clear();
|
||||
st.history.redo.clear();
|
||||
st.revision += 1;
|
||||
Ok(json!({ "cleared": cleared }))
|
||||
}
|
||||
|
||||
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
|
||||
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
|
||||
match like {
|
||||
|
||||
@@ -139,6 +139,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "`params` must be an object"));
|
||||
}
|
||||
super::params::check_effect_color(C, id, ¶ms)?;
|
||||
super::params::check_effect_channel(C, id, ¶ms)?;
|
||||
// Crop Marks take their style from the preferences when applied.
|
||||
if id == effects::CROP_MARKS && params.get("style").is_none() {
|
||||
params["style"] = json!(s.crop_mark_style().id());
|
||||
@@ -317,6 +318,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let edited = set_params_effects(s, p);
|
||||
for effect in &edited {
|
||||
super::params::check_effect_color(C, effect, ¶ms)?;
|
||||
super::params::check_effect_channel(C, effect, ¶ms)?;
|
||||
}
|
||||
if edited.iter().any(|e| e == GLASS) {
|
||||
load_texture(&mut params, C)?;
|
||||
|
||||
@@ -318,7 +318,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
|
||||
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
|
||||
let content = if g.gradient.kind == GradientKind::Freeform {
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
|
||||
} else {
|
||||
let frame = Frame::new(g, bounds);
|
||||
@@ -720,29 +720,24 @@ fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option
|
||||
|
||||
/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is
|
||||
/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the
|
||||
/// colour model their stops (or freeform points) share; display RGB where models differ.
|
||||
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
|
||||
/// color model their stops share; display RGB where models differ. Freeform points mix in the
|
||||
/// model they share ([`vectorcraft_color::Freeform::model_field`]); where their models differ, the
|
||||
/// RGB color they mix to is stored in `doc_model` (the document's model) the way `paint.setFill`
|
||||
/// stores an RGB color ([`super::paint::stored_color`]).
|
||||
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
|
||||
if g.gradient.kind == GradientKind::Freeform {
|
||||
let b = bounds.abs();
|
||||
let f = g.freeform_on(b);
|
||||
let model = shared_model(f.points.iter().map(|p| p.color));
|
||||
let field = f.field(spread_scale(b));
|
||||
let field = g.freeform_on(b).model_field(spread_scale(b));
|
||||
let store_in = (field.model().is_none() && doc_model != Model::Rgb).then_some(doc_model);
|
||||
return Box::new(move |p| {
|
||||
let ([r, g, b], a) = field.sample(p);
|
||||
let c = Color::rgb(r, g, b);
|
||||
(model.map_or(c, |m| c.in_model(m)), a)
|
||||
let (c, a) = field.sample(p);
|
||||
(super::paint::stored_color(store_in, c), a)
|
||||
});
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
|
||||
}
|
||||
|
||||
/// The colour model all of `colors` share.
|
||||
fn shared_model(mut colors: impl Iterator<Item = Color>) -> Option<Model> {
|
||||
let m = colors.next()?.model();
|
||||
colors.all(|c| c.model() == m).then_some(m)
|
||||
}
|
||||
|
||||
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
|
||||
/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint
|
||||
/// off the centre, as (parameter, colour, opacity) in order, with more between them where they
|
||||
|
||||
@@ -172,7 +172,7 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
|
||||
|
||||
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
|
||||
/// layers are guides and are left out.
|
||||
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
|
||||
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
|
||||
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
|
||||
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
|
||||
//! the art under it stays as it was. With `omit` they are left out.
|
||||
//! the art under it stays as it was. With `omit` they are left out. An image that can't be decoded
|
||||
//! is missing from the images that flattening and expanding raster effects draw, and the warnings
|
||||
//! then carry the raster exports' note once.
|
||||
//!
|
||||
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
|
||||
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
|
||||
@@ -33,15 +35,20 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath};
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, Encoded, Format};
|
||||
|
||||
/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error.
|
||||
pub(super) fn wanted(cmd: &str, p: &Value) -> Result<bool> {
|
||||
match p.get("aiVersion") {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false),
|
||||
Some(Value::String(v)) if v.trim() == "7" => Ok(true),
|
||||
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true),
|
||||
Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
|
||||
/// Does `p` ask a save in `f` for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown
|
||||
/// version is an error, and so is version 7 for a `.ait` template, which is PDF-compatible only.
|
||||
pub(super) fn wanted(cmd: &str, f: &Format, p: &Value) -> Result<bool> {
|
||||
let legacy = match p.get("aiVersion") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => false,
|
||||
Some(Value::String(v)) if v.trim() == "7" => true,
|
||||
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => true,
|
||||
Some(v) => return Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
|
||||
};
|
||||
if legacy && f.id == "ait" {
|
||||
return Err(bad(cmd, "aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible"));
|
||||
}
|
||||
Ok(legacy)
|
||||
}
|
||||
|
||||
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
|
||||
@@ -104,6 +111,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
enc.warnings.push(SEPARATE.to_string());
|
||||
}
|
||||
let created = vectorcraft_doc::metadata::now_unix();
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
for b in boards {
|
||||
let one = super::save::artboard_doc(doc, b);
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(&one);
|
||||
@@ -127,6 +135,11 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
}
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
}
|
||||
// Expanding raster effects and flattening images draw the art, and an image the renderer
|
||||
// couldn't decode drew nothing there.
|
||||
if let Some(w) = super::super::rasterfx::undecoded_warning(failed) {
|
||||
note(&mut enc.warnings, w);
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod pdf;
|
||||
mod pdfimport;
|
||||
pub mod pngtext;
|
||||
pub mod ppi;
|
||||
pub mod progress;
|
||||
pub mod psdread;
|
||||
mod save;
|
||||
mod screens;
|
||||
@@ -75,7 +76,7 @@ pub use save::{
|
||||
};
|
||||
pub use screens::{
|
||||
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
|
||||
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
|
||||
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, 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, safe_file_name,
|
||||
@@ -115,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
@@ -124,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -813,7 +814,8 @@ pub fn format_for_name(name: &str) -> Option<&'static Format> {
|
||||
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
|
||||
}
|
||||
|
||||
/// The format to write: `format` (an id or extension), else the path's extension, else PNG.
|
||||
/// The format to write: `format` (an id or extension), else the path's extension, else PNG. For a
|
||||
/// `.ait` template it returns an error that names `file.saveAsTemplate`, which writes it.
|
||||
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
|
||||
// Formats that can't be written say what to use instead.
|
||||
let unknown = |what: String, key: &str| unsupported(key).map_or(what, |u| u.hint.to_string());
|
||||
@@ -822,7 +824,13 @@ pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::r
|
||||
(None, Some(e)) => format(&e).ok_or_else(|| unknown(format!("unknown extension `.{e}`: pass `format` (see document.formats)"), &e))?,
|
||||
(None, None) => format("png").ok_or("no PNG encoder")?,
|
||||
};
|
||||
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
|
||||
if f.write {
|
||||
Ok(f)
|
||||
} else if TEMPLATE_FORMATS.contains(&f.id) {
|
||||
Err(format!("{} files are written by file.saveAsTemplate", f.label))
|
||||
} else {
|
||||
Err(format!("{} files can be opened but not written (see document.formats)", f.label))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`writable_format`] for command `cmd`.
|
||||
|
||||
@@ -103,7 +103,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "aiVersion",
|
||||
ty: "string",
|
||||
default: "\"pdf\"",
|
||||
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply)",
|
||||
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply; an error for a .ait template)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "transparentImages",
|
||||
|
||||
@@ -235,11 +235,15 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
super::eps::with_flattener(s, cmd, expanded)
|
||||
}
|
||||
|
||||
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
|
||||
/// The full export options for `doc`: settings plus the artboards `p` picks (default all), created
|
||||
/// at `doc`'s File Info creation date (the time of writing when it has none) and modified at the
|
||||
/// time of writing. On the web, which has no clock, the creation date is the modification date too,
|
||||
/// and a document without one gets neither.
|
||||
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() })
|
||||
let now = vectorcraft_doc::metadata::now_unix();
|
||||
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Why a PDF of some artboards carries no editing data.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Progress and Cancel of an export running off the UI thread (Background Export), on the shared
|
||||
//! [`vectorcraft_doc::cancel`] mechanism.
|
||||
//!
|
||||
//! The frontend makes a [`Progress`], runs the export inside [`scope`] on its worker and reads the
|
||||
//! counts from the UI thread to draw them; its Cancel button calls [`Progress::cancel`]. Inside the
|
||||
//! scope the encoders that work in steps (raster formats: one artboard at a time) report each step
|
||||
//! through [`vectorcraft_doc::cancel::progress`] and stop at the next check once cancelled; the
|
||||
//! renderer stops drawing and keeps no half-drawn pattern tile or effect raster. Encoders that
|
||||
//! make one file in one go report nothing: the frontend shows them as busy and checks
|
||||
//! [`Progress::cancelled`] itself before writing, so a cancelled export never writes a file.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink};
|
||||
|
||||
/// How far an export has got, shared between the worker and the UI thread.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Progress {
|
||||
token: CancelToken,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go).
|
||||
pub fn counts(&self) -> (u64, u64) {
|
||||
let total = self.total.load(Ordering::Relaxed);
|
||||
(self.done.load(Ordering::Relaxed).min(total), total)
|
||||
}
|
||||
|
||||
/// The share of the export done, 0 to 1; `None` while the export can't say.
|
||||
pub fn fraction(&self) -> Option<f64> {
|
||||
let (done, total) = self.counts();
|
||||
(total > 0).then(|| done as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// Ask the export to stop: it writes nothing more, and a render under way stops drawing.
|
||||
pub fn cancel(&self) {
|
||||
self.token.cancel();
|
||||
}
|
||||
|
||||
pub fn cancelled(&self) -> bool {
|
||||
self.token.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with `progress`'s token in effect on this thread and the steps reported to it.
|
||||
pub fn scope<R>(progress: &Arc<Progress>, f: impl FnOnce() -> R) -> R {
|
||||
let to = Arc::clone(progress);
|
||||
let sink: ProgressSink = Rc::new(move |done, total, _| {
|
||||
to.total.store(total, Ordering::Relaxed);
|
||||
to.done.store(done, Ordering::Relaxed);
|
||||
});
|
||||
cancel::scope(&progress.token, Some(sink), f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_counts_its_artboards_and_stops_once_cancelled() {
|
||||
let mut doc = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
let second = doc.artboards[0].clone();
|
||||
doc.artboards.push(second);
|
||||
let p = Progress::new();
|
||||
assert_eq!(p.fraction(), None, "nothing reported yet");
|
||||
let png = scope(&p, || super::super::encode_all(&doc, "png", &serde_json::json!({"useArtboards": true})));
|
||||
assert!(png.is_ok(), "{:?}", png.err());
|
||||
assert_eq!(p.counts(), (1, 2), "the step under way when the last artboard started");
|
||||
p.cancel();
|
||||
let r = scope(&p, || super::super::encode_all(&doc, "png", &serde_json::json!({"useArtboards": true})));
|
||||
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err());
|
||||
assert!(!cancel::is_cancelled(), "the scope ended");
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
// A legacy .ai file is a file per artboard already.
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? {
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
|
||||
@@ -424,7 +424,7 @@ const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens i
|
||||
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
|
||||
/// same file).
|
||||
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if super::legacy_ai::wanted(cmd, p)? {
|
||||
if super::legacy_ai::wanted(cmd, f, p)? {
|
||||
return super::legacy_ai::encode(cmd, f, doc, p);
|
||||
}
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
@@ -508,7 +508,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
}
|
||||
let boards = separate_boards(cmd, plan.format, &doc, ¶ms)?;
|
||||
let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.into_owned();
|
||||
let legacy = super::legacy_ai::wanted(cmd, ¶ms)?;
|
||||
let legacy = super::legacy_ai::wanted(cmd, plan.format, ¶ms)?;
|
||||
if let (Some(o), "ai" | "ait") = (params.as_object_mut(), plan.format.id) {
|
||||
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
|
||||
// artboard keeps a range given: the artboards to save.
|
||||
@@ -558,7 +558,7 @@ impl SaveJob {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
|
||||
// A legacy .ai file per artboard.
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?;
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?;
|
||||
if enc.files.len() != 1 && !per_artboard {
|
||||
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
|
||||
}
|
||||
@@ -797,7 +797,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save as Template…",
|
||||
["File"],
|
||||
None,
|
||||
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai; aiVersion 7 is an error); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Template, p)
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::export::{isolated, selected_artwork, without_artboards};
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names,
|
||||
@@ -342,6 +342,97 @@ impl ScreenParams {
|
||||
fn of(p: &Value) -> Result<Self> {
|
||||
if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) }
|
||||
}
|
||||
|
||||
/// An error when more than one of `assets`, `layers`, `selection` and `fullDocument` is given.
|
||||
fn one_source(&self) -> Result<()> {
|
||||
let sources = [self.full_document, self.assets.is_some(), self.layers.is_some(), self.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `selection`: `None` without it, `Some(false)` for `one` file, `Some(true)` for `each` object.
|
||||
fn selection_mode(&self) -> Result<Option<bool>> {
|
||||
match self.selection.as_deref() {
|
||||
None => Ok(None),
|
||||
Some("one") => Ok(Some(false)),
|
||||
Some("each") => Ok(Some(true)),
|
||||
Some(m) => Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a batch export names its files and sorts them into folders.
|
||||
struct Naming<'a> {
|
||||
/// The `name` pattern, when it holds more than spaces.
|
||||
pattern: Option<&'a str>,
|
||||
/// The `prefix`, through [`file_part`].
|
||||
prefix: String,
|
||||
/// The document's name, safe as a file name (`<filename>`, the Full Document's item).
|
||||
filename: String,
|
||||
/// The date and time the pattern's tokens use.
|
||||
when: [i64; 6],
|
||||
counter_start: u64,
|
||||
/// The counter counts files rather than items.
|
||||
per_file: bool,
|
||||
folders: Folders,
|
||||
}
|
||||
|
||||
impl<'a> Naming<'a> {
|
||||
/// The naming of params `o` for a document titled `title`.
|
||||
fn of(o: &'a ScreenParams, title: &str) -> Result<Self> {
|
||||
let folders = Folders::of(o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into());
|
||||
let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty());
|
||||
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders })
|
||||
}
|
||||
|
||||
/// The sub-folder (`None` for the export folder itself) and the file name of the file row `r`
|
||||
/// writes of `item`, the `index`th item (from 0), when `files` files came before it.
|
||||
fn file(&self, item: &Item, index: usize, files: u64, r: &Row) -> (Option<String>, String) {
|
||||
let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let prefix = &self.prefix;
|
||||
let stem = match self.pattern {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &self.filename,
|
||||
name: &item.name,
|
||||
index: index + 1,
|
||||
counter,
|
||||
format: ext,
|
||||
scale: &if r.format.raster { r.size.label() } else { String::new() },
|
||||
suffix: &r.suffix,
|
||||
when: self.when,
|
||||
};
|
||||
let named = naming::render(pattern, &parts);
|
||||
let named = if named.is_empty() { item.stem.clone() } else { named };
|
||||
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = safe_file_name(&format!("{stem}.{ext}"));
|
||||
let sub = match self.folders {
|
||||
Folders::None => None,
|
||||
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)),
|
||||
};
|
||||
(sub, file)
|
||||
}
|
||||
}
|
||||
|
||||
/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the
|
||||
@@ -566,36 +657,15 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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())?;
|
||||
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
|
||||
if sources > 1 {
|
||||
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
|
||||
}
|
||||
let folders = Folders::of(&o)?;
|
||||
// A prefix names files, not folders.
|
||||
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
|
||||
o.one_source()?;
|
||||
let naming = Naming::of(&o, &s.doc()?.doc.title)?;
|
||||
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
|
||||
let when = match o.date.as_deref() {
|
||||
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
|
||||
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
|
||||
};
|
||||
let per_file = match o.counter_per.as_deref() {
|
||||
None | Some("item") => false,
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let counter_start = o.counter_start.unwrap_or(1);
|
||||
let selection = match o.selection.as_deref() {
|
||||
None => None,
|
||||
Some("one") => Some(false),
|
||||
Some("each") => Some(true),
|
||||
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
|
||||
};
|
||||
let selection = o.selection_mode()?;
|
||||
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
if let Some(dir) = folder {
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let doc = s.doc()?.doc.clone();
|
||||
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
|
||||
// Include Bleed: each artboard grown by the document's bleed.
|
||||
let doc = if o.include_bleed && doc.setup.has_bleed() {
|
||||
let mut d = (*doc).clone();
|
||||
@@ -607,7 +677,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc
|
||||
};
|
||||
let crop = rows.iter().any(|r| r.format.id != "pdf");
|
||||
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
|
||||
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
|
||||
let mut out = Output::default();
|
||||
// The encoders' warnings for every file, each once.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
@@ -626,34 +696,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut files = 0u64;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for r in &rows {
|
||||
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
|
||||
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
|
||||
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
|
||||
Some(pattern) => {
|
||||
let parts = NameParts {
|
||||
filename: &filename,
|
||||
name: &item.name,
|
||||
index: index + 1,
|
||||
counter,
|
||||
format: ext,
|
||||
scale: &if r.format.raster { r.size.label() } else { String::new() },
|
||||
suffix: &r.suffix,
|
||||
when,
|
||||
};
|
||||
let named = naming::render(pattern, &parts);
|
||||
let named = if named.is_empty() { item.stem.clone() } else { named };
|
||||
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
|
||||
}
|
||||
None => format!("{prefix}{}{}", item.stem, r.suffix),
|
||||
};
|
||||
let file = safe_file_name(&format!("{stem}.{ext}"));
|
||||
let sub = match folders {
|
||||
Folders::None => None,
|
||||
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)),
|
||||
};
|
||||
let (sub, file) = naming.file(item, index, files, r);
|
||||
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
|
||||
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
|
||||
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
|
||||
@@ -675,7 +718,7 @@ 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 = safe_file_name(&format!("{prefix}{filename}.zip"));
|
||||
let zip_name = safe_file_name(&format!("{}{}.zip", naming.prefix, naming.filename));
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
let path = format!("{dir}/{zip_name}");
|
||||
@@ -697,12 +740,52 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The path, relative to the export folder, of the first file `document.exportForScreens` with
|
||||
/// params `p` writes (sub-folders included: `2x/[email protected]`): the first format row's file of the
|
||||
/// first artboard, asset, layer or selected object, or of the whole document, named as [`export`]
|
||||
/// names it. It encodes and writes nothing and cuts no art out of the document: of the objects
|
||||
/// selected for a file each, it measures only the first ones, up to the first that draws something.
|
||||
/// It does not refuse what the export refuses for drawing nothing: a checked asset or layer, the
|
||||
/// selection as one file, or the Full Document with no visible art.
|
||||
/// The Export for Screens dialog shows it on its Example line.
|
||||
pub fn first_file(s: &Session, p: &Value) -> Result<String> {
|
||||
let p = with_preset(s, p)?;
|
||||
let o = ScreenParams::of(&p)?;
|
||||
let rows = rows_of(s, &p, o.preset.as_deref())?;
|
||||
o.one_source()?;
|
||||
let selection = o.selection_mode()?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
|
||||
// A file per object: objects that draw nothing write no file.
|
||||
if selection == Some(true) {
|
||||
selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect();
|
||||
}
|
||||
let naming = Naming::of(&o, &doc.title)?;
|
||||
let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?;
|
||||
let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) };
|
||||
let (sub, file) = naming.file(item, 0, 0, r);
|
||||
Ok(sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}")))
|
||||
}
|
||||
|
||||
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
|
||||
/// selection (one item, or one per object), else the chosen artboards.
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
|
||||
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
|
||||
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF). `art`: each
|
||||
/// asset, layer and selected object is cut out of the document; without it, only the items' names
|
||||
/// are made, every item holds the whole document, and none is left out for drawing nothing.
|
||||
fn items(
|
||||
doc: &Arc<Document>,
|
||||
o: &ScreenParams,
|
||||
selection: Option<bool>,
|
||||
selected: &[NodeId],
|
||||
filename: &str,
|
||||
crop: bool,
|
||||
art: bool,
|
||||
) -> Result<Vec<Item>> {
|
||||
// The art of `ids` alone, as an item named `name`.
|
||||
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
|
||||
if !art {
|
||||
return Some((doc.clone(), Rect::ZERO));
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -135,16 +135,42 @@ fn a_pdfs_art_past_the_page_opens_whole_from_its_editing_data() {
|
||||
assert!(s.doc().unwrap().doc.art_bounds().is_some_and(|b| b.x1 < 200.5));
|
||||
}
|
||||
|
||||
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes:
|
||||
/// a `.ai`'s art outside its artboards is missing, and a PDF's art past the page is clipped.
|
||||
/// A `.ai` or a PDF whose editing data can't be decompressed opens from its PDF part with a note
|
||||
/// that its layers weren't read, and an export to its own path is refused unless it is given
|
||||
/// `acknowledgeLoss`.
|
||||
#[test]
|
||||
fn a_file_whose_editing_data_cant_be_decompressed_is_not_written_over_unasked() {
|
||||
let d = std::env::temp_dir().join(format!("vc-aiundecoded-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let damaged = b"%AI12_CompressedDataxx";
|
||||
for (name, file) in [("art.ai", ai::ai(damaged, ai::page_pdf())), ("art.pdf", ai::pdf(damaged, ai::page_pdf()))] {
|
||||
let path = d.join(name);
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("layers weren't read from its editing data (its zlib compression is damaged)"), "{name}: {r}");
|
||||
let e = s.execute("document.export", &json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(e.contains("left things out") && e.contains("layers weren't read"), "{name}: {e}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), file, "{name}");
|
||||
s.execute("document.export", &json!({"path": path, "acknowledgeLoss": true})).unwrap();
|
||||
assert_ne!(std::fs::read(&path).unwrap(), file, "{name}");
|
||||
}
|
||||
std::fs::remove_dir_all(d).ok();
|
||||
}
|
||||
|
||||
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes
|
||||
/// and then a note saying why its layers weren't read: a `.ai`'s art outside its artboards is
|
||||
/// missing, and a PDF's art past the page is clipped.
|
||||
#[test]
|
||||
fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
|
||||
let damaged = b"%AI12_CompressedData not zlib";
|
||||
let unread = "the file's layers weren't read from its editing data (its zlib compression is damaged): they come from its PDF part";
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE]), "{r}");
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE, unread]), "{r}");
|
||||
let r = open(&mut s, "damaged.pdf", &ai::pdf(damaged, "0 g 150 20 100 60 re f"), json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
|
||||
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE, unread]), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -158,6 +184,34 @@ fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
|
||||
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose document data has a variable library opens with a note that its variables and
|
||||
/// data sets weren't read, whether its layers are read or it comes in as its PDF part.
|
||||
#[test]
|
||||
fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() {
|
||||
const NOTE: &str = "the file's variables and data sets weren't read (2 variables, 3 data sets): the Variables panel doesn't list them";
|
||||
let library = ai::variable_library(&["Name", "Number"], &["One", "Two", "Three"]);
|
||||
let square = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
|
||||
let notes =
|
||||
|r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("variables")).cloned().collect::<Vec<_>>();
|
||||
let mut s = Session::new();
|
||||
let layered = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", &square)));
|
||||
let file = ai::ai(&compressed(layered.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
|
||||
let r = open(&mut s, "variables.ai", &file, json!({})).unwrap();
|
||||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||||
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
|
||||
let r = s.execute("variable.list", &json!({})).unwrap();
|
||||
assert_eq!(r["variables"], json!([]), "{r}");
|
||||
// Its layers can't be read (a symbol): it comes in as its PDF part, with the note.
|
||||
let symbols = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")));
|
||||
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("PDF part"), "{r}");
|
||||
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
|
||||
// Without a library, no note.
|
||||
let plain = ai::editing_data(200.0, 100.0, &ai::layer("Art", &square));
|
||||
let r = open(&mut s, "plain.ai", &ai::ai(&compressed(plain.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||||
assert!(notes(&r).is_empty(), "{r}");
|
||||
}
|
||||
|
||||
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
|
||||
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
|
||||
|
||||
|
||||
@@ -131,6 +131,56 @@ fn options_not_applied_yet_and_document_features_warn() {
|
||||
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
|
||||
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
|
||||
/// raster exports give, once.
|
||||
#[test]
|
||||
fn every_pdf_leaves_out_an_image_that_cant_be_decoded() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut s = session(1);
|
||||
let cut = crate::tests_menucmds::place_undecodable_png(&mut s);
|
||||
// Whether each object on the PDF's pages is an image. The pages should hold the rectangle and
|
||||
// no image.
|
||||
let drawn = |bytes: &[u8]| {
|
||||
let mut images = vec![];
|
||||
vectorcraft_pdf::import(bytes).unwrap().walk(|n| {
|
||||
if !n.is_container() && !matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: true, .. }) {
|
||||
images.push(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
|
||||
}
|
||||
});
|
||||
images
|
||||
};
|
||||
for (cmd, p) in [
|
||||
("document.exportPdf", json!({})),
|
||||
("document.exportPdf", json!({"preset": "High Quality Print"})),
|
||||
("document.exportPdf", json!({"standard": "pdfX1a"})),
|
||||
("document.export", json!({"format": "pdf"})),
|
||||
("file.saveCopy", json!({"format": "pdf"})),
|
||||
("file.saveCopy", json!({"format": "ai"})),
|
||||
("file.print", json!({})),
|
||||
] {
|
||||
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||||
assert_eq!(warnings(&v).iter().filter(|w| *w == NOTE).count(), 1, "{cmd} {p}: {}", v["warnings"]);
|
||||
assert_eq!(drawn(&b64(&v)), [false], "{cmd} {p}");
|
||||
}
|
||||
// The .ai file reopens with the image as it was placed, from the image bytes in its editing data.
|
||||
let ai = b64(&s.execute("file.saveCopy", &json!({"format": "ai"})).unwrap());
|
||||
let placed = s.doc().unwrap().doc.images.clone();
|
||||
s.execute("document.open", &json!({"name": "cut.ai", "dataBase64": vectorcraft_format::base64_encode(&ai)})).unwrap();
|
||||
let reopened = &s.doc().unwrap().doc;
|
||||
assert!(reopened.node(cut).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))));
|
||||
assert_eq!(reopened.images, placed);
|
||||
// Export for Screens, a native file carrying a PDF, and the PDF that Copy offers are written.
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "pdf"}]})).unwrap();
|
||||
assert_eq!(drawn(&b64(&r["files"][0])), [false]);
|
||||
assert!(s.execute("file.saveCopy", &json!({"format": "vectorcraft", "pdfCompatible": true})).is_ok());
|
||||
s.execute("select.set", &json!({"ids": [cut.0]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert!(b64(&s.execute("clipboard.exportPdf", &json!({})).unwrap()).starts_with(b"%PDF"));
|
||||
s.execute("prefs.set", &json!({"key": "copyAsPdf", "value": true})).unwrap();
|
||||
assert!(s.clipboard_flavours().iter().any(|f| f.mime == crate::cmd::clipboard::PDF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_writes_a_path() {
|
||||
let mut s = session(2);
|
||||
|
||||
@@ -232,6 +232,52 @@ fn save_as_template_names_its_file_after_the_template_format() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exports don't write a `.ait`: each export asked for one, by its path or `format`, answers an
|
||||
/// error that names `file.saveAsTemplate`, which writes it, and writes nothing. A format that
|
||||
/// nothing writes keeps its own error.
|
||||
#[test]
|
||||
fn exports_of_an_ait_name_save_as_template() {
|
||||
let mut s = rich();
|
||||
let path = tmp("exported.ait");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
for (cmd, p) in [
|
||||
("document.export", json!({"path": path})),
|
||||
("document.export", json!({"format": "ait"})),
|
||||
("document.serialize", json!({"format": "ait"})),
|
||||
("document.exportSelection", json!({"path": path})),
|
||||
("document.exportForScreens", json!({"formats": [{"format": "ait"}]})),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("PDF-compatible .ait template files are written by file.saveAsTemplate"), "{cmd} {p}: {e}");
|
||||
}
|
||||
assert!(!std::path::Path::new(&path).exists(), "nothing written");
|
||||
assert_eq!(writable_format(Some("ps"), None).unwrap_err(), "PostScript files can be opened but not written (see document.formats)");
|
||||
}
|
||||
|
||||
/// Save as Template refuses `aiVersion` 7 for a `.ait`, given in `options` or flat, and writes no
|
||||
/// file; `aiVersion` "pdf" writes the PDF-compatible template.
|
||||
#[test]
|
||||
fn save_as_template_refuses_a_legacy_ait() {
|
||||
let mut s = rich();
|
||||
let path = tmp("legacy-ait/card.ait");
|
||||
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for p in [
|
||||
json!({"path": path, "options": {"aiVersion": "7"}}),
|
||||
json!({"path": path, "format": "ait", "aiVersion": 7}),
|
||||
json!({"format": "ait", "options": {"aiVersion": "7"}}),
|
||||
] {
|
||||
let e = s.execute("file.saveAsTemplate", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("aiVersion 7 writes legacy .ai files"), "{p}: {e}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing written");
|
||||
let r = s.execute("file.saveAsTemplate", &json!({"path": path, "options": {"aiVersion": "pdf"}})).unwrap();
|
||||
assert_eq!(r["format"], "ait", "{r}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_and_screens_leave_the_editing_data_out() {
|
||||
let mut s = rich();
|
||||
|
||||
@@ -59,6 +59,23 @@ fn layers_option_keeps_or_flattens_optional_content() {
|
||||
assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false"));
|
||||
}
|
||||
|
||||
/// A PDF-compatible `.ai` without the editor's private data opens its layers from its optional
|
||||
/// content groups, whose names without a byte order mark are PDFDocEncoding.
|
||||
#[test]
|
||||
fn ai_layer_names_are_read_as_pdf_doc_encoding() {
|
||||
let (a, b) = (first_extra(1), first_extra(1) + 1);
|
||||
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << >> >> ");
|
||||
let page = PdfPage {
|
||||
resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"),
|
||||
..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC")
|
||||
};
|
||||
let bytes = pdf_with_catalog(&[page], &[r"<< /Type /OCG /Name (Art\205final) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None);
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "art.ai", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
let names: Vec<String> = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Art\u{2013}final", "Notes"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_options_read_text_and_layers() {
|
||||
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap();
|
||||
|
||||
@@ -146,6 +146,63 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() {
|
||||
assert_eq!(r["name"], "nul-.zip");
|
||||
}
|
||||
|
||||
/// `first_file` names the file the export writes first, for the same params: the item's file name,
|
||||
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
|
||||
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
|
||||
/// export preset, a ZIP, Full Document, Include Bleed, assets, layers and the selection; and it
|
||||
/// refuses what the export refuses.
|
||||
#[test]
|
||||
fn the_first_file_is_the_file_the_export_writes_first() {
|
||||
let mut s = session(3);
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "name": "CON"})).unwrap();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Logo?"})).unwrap()["assets"][0].as_u64().unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let written = |r: &Value| r["files"][0].as_str().or_else(|| r["files"][0]["name"].as_str()).unwrap_or_default().to_string();
|
||||
let cases = [
|
||||
json!({}),
|
||||
json!({"prefix": "a?b/", "formats": [{"format": "png", "suffix": "<x>"}]}),
|
||||
json!({"range": "2-3", "folders": "item"}),
|
||||
json!({"range": "2", "folders": "itemFormat", "formats": [{"format": "svg", "scale": "2x", "suffix": "@2x"}]}),
|
||||
json!({"preset": "mobile"}),
|
||||
json!({"preset": "density"}),
|
||||
json!({"subfolders": true, "formats": [{"format": "png", "scale": "144ppi", "folder": "a b"}]}),
|
||||
json!({"folders": "format", "formats": [{"format": "jpg", "quality": 50}]}),
|
||||
json!({
|
||||
"name": "<filename>-<name>-<scale>-<index>-###-YYYYMMDD",
|
||||
"counterStart": 7,
|
||||
"counterPer": "file",
|
||||
"date": "2026-10-17",
|
||||
"formats": [{"format": "png", "scale": "2x"}]
|
||||
}),
|
||||
json!({"name": "<suffix>", "formats": [{"format": "png", "suffix": ""}]}),
|
||||
json!({"exportPreset": "Logo Package"}),
|
||||
json!({"zip": true, "formats": [{"format": "svg"}, {"format": "png"}]}),
|
||||
json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}),
|
||||
json!({"fullDocument": true, "formats": [{"format": "png"}]}),
|
||||
json!({"includeBleed": true}),
|
||||
json!({"assets": [asset], "formats": [{"format": "svg"}]}),
|
||||
json!({"layers": [layer], "folders": "item"}),
|
||||
json!({"selection": "one"}),
|
||||
json!({"selection": "each", "name": "<name>"}),
|
||||
];
|
||||
for p in cases {
|
||||
let first = super::screens::first_file(&s, &p).unwrap();
|
||||
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
for p in [json!({"layers": [layer], "assets": [asset]}), json!({"selection": "all"}), json!({"counterPer": "row"})] {
|
||||
assert!(super::screens::first_file(&s, &p).is_err() && s.execute("document.exportForScreens", &p).is_err(), "{p}");
|
||||
}
|
||||
// A document named after a Windows device.
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
|
||||
for p in [json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}), json!({"name": "<filename>"})] {
|
||||
let first = super::screens::first_file(&s, &p).unwrap();
|
||||
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn sub_folders_are_made_in_the_export_folder() {
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
|
||||
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
|
||||
has_doc,
|
||||
add_point
|
||||
),
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Freeform Point",
|
||||
[],
|
||||
None,
|
||||
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
|
||||
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
|
||||
has_doc,
|
||||
set_point
|
||||
),
|
||||
@@ -134,6 +134,12 @@ fn point_fields(v: &Value, pt: &mut FreeformPoint, to_paint: Affine) -> std::res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `color` of `p` as the document stores it: an RGB color in a CMYK document becomes CMYK
|
||||
/// through the color settings ([`super::paint::new_color`]). `None` without a valid one.
|
||||
fn given_color(s: &Session, p: &Value) -> Option<vectorcraft_color::Color> {
|
||||
p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c))
|
||||
}
|
||||
|
||||
/// Parse the `freeform` param of a gradient paint (`{points: [{at, color, opacity?, spread?}],
|
||||
/// lines?: [[index, …]], mode?}`; points in the paint's space). Lossless for
|
||||
/// `vectorcraft_tools::params::freeform_json`.
|
||||
@@ -238,11 +244,15 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::Bool(true)) => s.selected_freeform_point(),
|
||||
Some(_) => return Err(bad(C, "`line` must be true or false")),
|
||||
};
|
||||
let given = given_color(s, p);
|
||||
let index = edit(s, p, C, |f, sp| {
|
||||
let q = sp.to_paint * at;
|
||||
let (color, opacity) = f.sample(q, sp.scale);
|
||||
let mut pt = FreeformPoint { opacity, ..FreeformPoint::new(q, color) };
|
||||
point_fields(p, &mut pt, sp.to_paint)?;
|
||||
if let Some(c) = given {
|
||||
pt.color = c;
|
||||
}
|
||||
let i = f.add_point(pt);
|
||||
if let Some(from) = join {
|
||||
f.connect(from, i);
|
||||
@@ -256,9 +266,14 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.setPoint";
|
||||
let i = point_index(s, p, C)?;
|
||||
let given = given_color(s, p);
|
||||
edit(s, p, C, |f, sp| {
|
||||
check_index(f, i)?;
|
||||
point_fields(p, &mut f.points[i], sp.to_paint)
|
||||
point_fields(p, &mut f.points[i], sp.to_paint)?;
|
||||
if let Some(c) = given {
|
||||
f.points[i].color = c;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
@@ -137,6 +137,47 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Stop colors to store, one per stop of `g` (params holding `stops`, before [`link_stops`]): the
|
||||
/// color a stop gives and the color the document stores it as when it is new to the gradient, an
|
||||
/// RGB color in a CMYK document becoming CMYK through the color settings
|
||||
/// ([`super::paint::new_color`], with `p`'s `keepModel`). `None` for a stop that names a swatch,
|
||||
/// which gives its color in the swatch's model, and for a color stored as given.
|
||||
pub(crate) type StopColors = Vec<Option<(vectorcraft_color::Color, vectorcraft_color::Color)>>;
|
||||
|
||||
/// The [`StopColors`] of `g`'s stops.
|
||||
pub(crate) fn stop_colors(s: &Session, p: &Value, g: &Value) -> StopColors {
|
||||
let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
let stored = |st: &Value| {
|
||||
let c = st.get("color").and_then(color_value).filter(|_| str_param(st, "swatch").is_none())?;
|
||||
let to = super::paint::new_color(s, p, c);
|
||||
(to != c).then_some((c, to))
|
||||
};
|
||||
stops.iter().map(stored).collect()
|
||||
}
|
||||
|
||||
/// `g` (params holding `stops`, resolved by [`link_stops`] in their order) for an edit of paint
|
||||
/// `old`, with each stop's color replaced by the one `stored` ([`stop_colors`]) gives for it,
|
||||
/// unless `old`'s stops already have that color. A freeform gradient's points are colored from the
|
||||
/// stored colors, and a stop list sent back with only its positions changed keeps its colors.
|
||||
pub(crate) fn with_stored_stops<'a>(g: &'a Value, old: &Paint, stored: &StopColors) -> std::borrow::Cow<'a, Value> {
|
||||
use std::borrow::Cow;
|
||||
if stored.iter().all(Option::is_none) {
|
||||
return Cow::Borrowed(g);
|
||||
}
|
||||
let had = |c: &vectorcraft_color::Color| matches!(old, Paint::Gradient(o) if o.gradient.stops.iter().any(|st| st.color == *c));
|
||||
let mut v = g.clone();
|
||||
let Some(stops) = v.get_mut("stops").and_then(Value::as_array_mut) else { return Cow::Borrowed(g) };
|
||||
for (st, sc) in stops.iter_mut().zip(stored) {
|
||||
let Some((from, to)) = sc.filter(|(from, _)| !had(from)) else { continue };
|
||||
if st.get("color").and_then(color_value) == Some(from)
|
||||
&& let Some(o) = st.as_object_mut()
|
||||
{
|
||||
o.insert("color".into(), color_json(&to));
|
||||
}
|
||||
}
|
||||
Cow::Owned(v)
|
||||
}
|
||||
|
||||
/// Parse `stops` params: at least two, each with a numeric `offset` (clamped to 0..1) and a valid
|
||||
/// `color`; `opacity` is 0..1 (values above 1 are percentages) and `midpoint` is clamped to the
|
||||
/// diamond's range; `swatch` and `tint` (%) keep a stop's link as given (resolve them first with
|
||||
@@ -340,11 +381,13 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
|
||||
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
|
||||
};
|
||||
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
|
||||
let stored = stop_colors(s, p, p);
|
||||
// Stops naming swatches take their colours from them.
|
||||
let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?;
|
||||
// Validate against the defaults first so bad params fail without touching the document.
|
||||
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
|
||||
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
|
||||
let new_default = apply_gradient_edit(&default_paint, &with_stored_stops(p, &default_paint, &stored), None).map_err(|e| bad(C, e))?;
|
||||
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
|
||||
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
|
||||
if index.is_none()
|
||||
@@ -353,7 +396,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lb = t.local_bounds();
|
||||
for r in &mut t.runs {
|
||||
let (cur, b) = run_paint_mut(r, stroke, lb);
|
||||
*cur = apply_gradient_edit(cur, p, Some(b)).map_err(|e| bad(C, e))?;
|
||||
*cur = apply_gradient_edit(cur, &with_stored_stops(p, cur, &stored), Some(b)).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -364,7 +407,8 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(f) => f,
|
||||
None => &|_| true,
|
||||
};
|
||||
let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?;
|
||||
let old = n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None);
|
||||
let np = apply_gradient_edit_in(old, &with_stored_stops(p, old, &stored), b, inside).map_err(|e| bad(C, e))?;
|
||||
n.appearance.set_paint_at(index, !stroke, np);
|
||||
if let Some(m) = mode
|
||||
&& let Some(st) = n.appearance.stroke_at_mut(index)
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Mesh Point Color",
|
||||
[],
|
||||
None,
|
||||
"{id, index, color, opacity?: 0..1} colour of one mesh point",
|
||||
"{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point",
|
||||
has_doc,
|
||||
mesh_set_color
|
||||
),
|
||||
@@ -274,7 +274,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Mesh Line",
|
||||
[],
|
||||
None,
|
||||
"{id, x, y, color?} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
|
||||
"{id, x, y, color?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
|
||||
has_doc,
|
||||
mesh_add_line
|
||||
),
|
||||
@@ -410,6 +410,7 @@ const DEFAULT_STEPS: u32 = 5;
|
||||
const DEFAULT_DISTANCE: f64 = 10.0;
|
||||
|
||||
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
|
||||
check_numbers(p, &["value", "steps", "distance"], cmd)?;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64);
|
||||
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
|
||||
let mode = str_param(p, "spacing");
|
||||
@@ -971,7 +972,11 @@ fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- Envelope ----------
|
||||
|
||||
/// The number keys of a warp: `bend`, and the two distortions by their short and long names.
|
||||
const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"];
|
||||
|
||||
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
|
||||
check_numbers(p, WARP_NUMBERS, cmd)?;
|
||||
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
|
||||
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
|
||||
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
|
||||
@@ -1005,6 +1010,7 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
|
||||
|
||||
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
|
||||
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
|
||||
check_numbers(p, &["rows", "cols", "columns"], cmd)?;
|
||||
let rows = f64_or(p, "rows", default.0 as f64);
|
||||
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
|
||||
let cols = f64_or(p, cols_key, default.1 as f64);
|
||||
@@ -1241,6 +1247,8 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.options";
|
||||
check_numbers(p, &["fidelity"], C)?;
|
||||
check_numbers(p, WARP_NUMBERS, C)?;
|
||||
let fid = p.get("fidelity").and_then(Value::as_f64);
|
||||
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
|
||||
return Err(bad(C, "fidelity must be 0..100"));
|
||||
@@ -1386,14 +1394,19 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?,
|
||||
None => MeshAppearance::Flat,
|
||||
};
|
||||
check_numbers(p, &["highlight"], C)?;
|
||||
let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0);
|
||||
let at = point_param(p, "at");
|
||||
let at = match p.get("at") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?),
|
||||
};
|
||||
let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) };
|
||||
let roots = roots_param(s, p)?;
|
||||
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut index = None;
|
||||
let mut refused = None;
|
||||
let model = d.color_mode.model();
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let m = if let NodeKind::Image(im) = &n.kind {
|
||||
@@ -1413,7 +1426,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
@@ -1521,8 +1534,9 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
|
||||
|
||||
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.setPointColor";
|
||||
check_numbers(p, &["opacity"], C)?;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let color = p.get("color").and_then(color_value);
|
||||
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64);
|
||||
if color.is_none() && opacity.is_none() {
|
||||
return Err(bad(C, "missing `color` or `opacity`"));
|
||||
@@ -1559,7 +1573,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.addLine";
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let color = p.get("color").and_then(color_value);
|
||||
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
|
||||
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
|
||||
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
|
||||
if let Some(c) = color {
|
||||
|
||||
@@ -181,6 +181,10 @@ pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into())
|
||||
}
|
||||
/// The active document has a step to undo or to redo (Clear History).
|
||||
pub fn can_clear_history(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| !d.history.undo.is_empty() || !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "no history to clear".into())
|
||||
}
|
||||
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
|
||||
|
||||
@@ -301,7 +301,7 @@ fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Mod
|
||||
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
|
||||
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
|
||||
/// are returned as given.
|
||||
fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
|
||||
pub(crate) fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
|
||||
match (c, model) {
|
||||
(Color::Rgb { .. }, Some(m)) => {
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
@@ -318,8 +318,8 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
|
||||
}
|
||||
|
||||
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
|
||||
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]),
|
||||
/// converted through the color settings.
|
||||
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
|
||||
/// model ([`new_color_model`]), converted through the color settings.
|
||||
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||||
let model = new_color_model(s, p);
|
||||
let new_color = |c: Color| stored_color(model, c);
|
||||
@@ -342,9 +342,17 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||||
};
|
||||
}
|
||||
if let Some(g) = p.get("gradient") {
|
||||
// A stop naming a process swatch keeps the swatch's model.
|
||||
let stored = super::gradient::stop_colors(s, p, g);
|
||||
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
|
||||
let mut gp = super::gradient::parse_gradient(&g).map_err(|e| bad("paint", e))?;
|
||||
gp.gradient.stops.iter_mut().filter(|st| st.swatch.is_none()).for_each(|st| st.color = new_color(st.color));
|
||||
let mut gp = super::gradient::parse_gradient(&super::gradient::with_stored_stops(&g, &Paint::None, &stored)).map_err(|e| bad("paint", e))?;
|
||||
if g.get("stops").is_none() {
|
||||
// The default stops, white to black.
|
||||
gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color));
|
||||
}
|
||||
if let Some(f) = &mut gp.freeform {
|
||||
f.points.iter_mut().for_each(|pt| pt.color = new_color(pt.color));
|
||||
}
|
||||
return Ok(Some(Paint::Gradient(Box::new(gp))));
|
||||
}
|
||||
if let Some(c) = p.get("color") {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Character/Paragraph attributes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Composer, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::*;
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -236,6 +236,34 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector
|
||||
/// Largest indent or paragraph spacing (points; Illustrator's limit).
|
||||
const MAX_PARA: f64 = 1296.0;
|
||||
|
||||
/// A justification spacing of `text.setFormat` (`wordSpacing`, `letterSpacing`, `glyphScaling`):
|
||||
/// `[min, desired, max]` or `{min?, desired?, max?}`, in percent. The values not given are kept.
|
||||
fn spacing_param(p: &Value, key: &str, c: &str) -> Result<Option<[Option<f64>; 3]>> {
|
||||
let number = |v: &Value| v.as_f64().filter(|v| v.is_finite());
|
||||
let err = || bad(c, format!("`{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent"));
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let mut out = [None; 3];
|
||||
for (o, v) in out.iter_mut().zip(a) {
|
||||
*o = Some(number(v).ok_or_else(err)?);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
Some(Value::Object(o)) => {
|
||||
let mut out = [None; 3];
|
||||
for (k, v) in o {
|
||||
let i = ["min", "desired", "max"].iter().position(|n| n == k).ok_or_else(err)?;
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = Some(number(v).ok_or_else(err)?);
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
Some(_) => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids = super::typecmd::text_targets(s, p, C)?;
|
||||
@@ -269,6 +297,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"hyphenLimit",
|
||||
"hyphenationZone",
|
||||
"hyphenateCapitalized",
|
||||
"wordSpacing",
|
||||
"letterSpacing",
|
||||
"glyphScaling",
|
||||
"autoLeading",
|
||||
"singleWordJustification",
|
||||
"mojikumi",
|
||||
"kinsoku",
|
||||
"direction",
|
||||
@@ -339,6 +372,24 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
|
||||
}),
|
||||
};
|
||||
let spacing = |k: &str| spacing_param(p, k, C);
|
||||
let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?);
|
||||
let single_word = match p.get("singleWordJustification") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("fullJustify") => SingleWordJustify::FullJustify,
|
||||
Some("alignLeft") => SingleWordJustify::AlignLeft,
|
||||
Some("alignCenter") => SingleWordJustify::AlignCenter,
|
||||
Some("alignRight") => SingleWordJustify::AlignRight,
|
||||
_ => return Err(bad(C, "`singleWordJustification` must be \"fullJustify\", \"alignLeft\", \"alignCenter\" or \"alignRight\"")),
|
||||
}),
|
||||
};
|
||||
let auto_leading = match p.get("autoLeading") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?),
|
||||
};
|
||||
// A paragraph whose Minimum, Desired and Maximum would no longer be in order.
|
||||
let mut out_of_order: Option<&str> = None;
|
||||
let range = super::typecmd::TextRange::parse(p, C)?;
|
||||
// The character attributes, given the baseline shift and horizontal scale in the type's own units.
|
||||
let chars = |st: &mut vectorcraft_doc::CharStyle, baseline_shift: Option<f64>, h_scale: Option<f64>| {
|
||||
@@ -430,6 +481,28 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("hyphenateCapitalized") {
|
||||
para.hyphenation.capitalized = v;
|
||||
}
|
||||
let j = &mut para.justification;
|
||||
for (name, given, value, limits) in [
|
||||
("wordSpacing", &word_spacing, &mut j.word_spacing, JustificationOptions::WORD_SPACING),
|
||||
("letterSpacing", &letter_spacing, &mut j.letter_spacing, JustificationOptions::LETTER_SPACING),
|
||||
("glyphScaling", &glyph_scaling, &mut j.glyph_scaling, JustificationOptions::GLYPH_SCALING),
|
||||
] {
|
||||
let Some(given) = given else { continue };
|
||||
for (v, g) in value.iter_mut().zip(given) {
|
||||
if let Some(g) = g {
|
||||
*v = g.clamp(limits.0, limits.1);
|
||||
}
|
||||
}
|
||||
if !(value[0] <= value[1] && value[1] <= value[2]) {
|
||||
out_of_order.get_or_insert(name);
|
||||
}
|
||||
}
|
||||
if let Some(v) = auto_leading {
|
||||
j.auto_leading = v.clamp(JustificationOptions::AUTO_LEADING.0, JustificationOptions::AUTO_LEADING.1);
|
||||
}
|
||||
if let Some(v) = single_word {
|
||||
j.single_word = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
@@ -452,6 +525,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para.composer = v;
|
||||
}
|
||||
});
|
||||
if let Some(name) = out_of_order {
|
||||
return Err(bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")));
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
// Graph labels keep a change made to all of their text through the graph being drawn again.
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
|
||||
//! what it is given, so the journal of an older version still replays, except for an effect's
|
||||
//! `color` that the effect can't read, which `effect.apply` and `effect.setParams` refuse
|
||||
//! themselves ([`check_effect_color`]).
|
||||
//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't
|
||||
//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`],
|
||||
//! [`check_effect_channel`]).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -214,9 +215,10 @@ fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of its
|
||||
/// default's type, and a `color` the effect can draw ([`check_effect_color`]). Numbers out of
|
||||
/// range pass: the effect clamps them when it draws. An effect plug-in resolves its own against
|
||||
/// its manifest, and an unknown effect is the command's to report.
|
||||
/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves
|
||||
/// `channel` the effect has ([`check_effect_channel`]). Numbers out of range pass: the effect
|
||||
/// clamps them when it draws. An effect plug-in resolves its own against its manifest, and an
|
||||
/// unknown effect is the command's to report.
|
||||
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
@@ -243,7 +245,8 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
check_effect_color(id, effect, params)
|
||||
check_effect_color(id, effect, params)?;
|
||||
check_effect_channel(id, effect, params)
|
||||
}
|
||||
|
||||
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
|
||||
@@ -280,6 +283,20 @@ pub(crate) fn check_effect_color(id: &str, effect: &str, params: &Value) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `channel` in `params` of Levels or Curves (`effect`), as command `id` was given it: a
|
||||
/// parameter error naming the channels unless the effect reads it
|
||||
/// ([`vectorcraft_render::effects::is_channel`]); the effect draws any other value as RGB.
|
||||
/// `effect.apply` and `effect.setParams` run this themselves before they edit, as they run
|
||||
/// [`check_effect_color`].
|
||||
pub(crate) fn check_effect_channel(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let key = "channel";
|
||||
let Some(v) = params.get(key) else { return Ok(()) };
|
||||
if !matches!(effect, "adjust.levels" | "adjust.curves") || v.as_str().is_some_and(vectorcraft_render::effects::is_channel) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(bad(id, format!("`{key}` must be one of {}", vectorcraft_render::effects::CHANNELS.join(", "))))
|
||||
}
|
||||
|
||||
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
|
||||
/// descriptions couldn't follow (a list of keys in a loop, a whole object read as settings), so
|
||||
/// their descriptions aren't proven to name every key they take. Sorted; take a command out once
|
||||
|
||||
@@ -210,14 +210,14 @@ fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
|
||||
fn unique_pattern_name(d: &Document, base: &str) -> String {
|
||||
if d.pattern(base).is_none() && d.swatch(base).is_none() {
|
||||
if d.pattern(base).is_none() && !d.swatch_name_taken(base) {
|
||||
return base.to_string();
|
||||
}
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap_or_else(|| base.to_string())
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && !d.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn next_pattern_name(d: &Document) -> String {
|
||||
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap_or_else(|| "New Pattern".to_string())
|
||||
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && !d.swatch_name_taken(n)).unwrap_or_else(|| "New Pattern".to_string())
|
||||
}
|
||||
|
||||
/// Add pattern `def` and its swatch.
|
||||
@@ -420,10 +420,14 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
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()
|
||||
{
|
||||
return Err(bad(C, format!("a pattern named `{n}` exists")));
|
||||
if let Some(n) = &new_name {
|
||||
let d = &s.doc()?.doc;
|
||||
if d.pattern(n).is_some() {
|
||||
return Err(bad(C, format!("a pattern named `{n}` exists")));
|
||||
}
|
||||
if swatch_name_clash(d, &name, n) {
|
||||
return Err(bad(C, format!("a swatch or color group named `{n}` exists")));
|
||||
}
|
||||
}
|
||||
let overlap = match p.get("overlap") {
|
||||
Some(o) => Overlap {
|
||||
@@ -479,7 +483,19 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": out_name }))
|
||||
}
|
||||
|
||||
/// Rename a pattern in swatches, paints and the edit record.
|
||||
/// Is `new` taken for pattern `old` by a swatch or a color group (they share one namespace,
|
||||
/// [`Document::swatch_name_taken`])? It is free when the swatches named `new` are one or more
|
||||
/// swatches of the pattern and none of the pattern's swatches is named `old` (those take the new
|
||||
/// name in [`rename_pattern_uses`]).
|
||||
fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool {
|
||||
let own = |w: &vectorcraft_color::Swatch| pattern_name(&w.paint).is_some_and(|p| p == old);
|
||||
let mut holders = d.swatches_iter().filter(|w| w.name == new).peekable();
|
||||
let only_own = holders.peek().is_some() && holders.all(own);
|
||||
d.swatch_name_taken(new) && !(only_own && !d.swatches_iter().any(|w| w.name == old && own(w)))
|
||||
}
|
||||
|
||||
/// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the
|
||||
/// pattern's own take the new name.
|
||||
fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) {
|
||||
let fix = |p: &mut Paint| {
|
||||
if let Paint::Pattern { pattern, .. } = p
|
||||
@@ -489,7 +505,7 @@ fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) {
|
||||
}
|
||||
};
|
||||
for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
|
||||
if sw.name == old {
|
||||
if sw.name == old && pattern_name(&sw.paint).is_some_and(|p| p == old) {
|
||||
sw.name = new.to_string();
|
||||
}
|
||||
fix(&mut sw.paint);
|
||||
|
||||
@@ -68,8 +68,9 @@ pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u
|
||||
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
|
||||
let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?;
|
||||
let layers = std::mem::take(&mut l.doc.layers);
|
||||
let nodes = super::on_artboard(&layers, board);
|
||||
let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) };
|
||||
let symbols = l.doc.symbol_boxes();
|
||||
let nodes = super::on_artboard(&symbols, &layers, board);
|
||||
let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) };
|
||||
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
|
||||
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling, SymbolBoxes};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_pdf::CropTo;
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -239,6 +239,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
doc.drop_edit_modes();
|
||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let symbols = doc.symbol_boxes();
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
|
||||
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
|
||||
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
|
||||
@@ -253,16 +254,28 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
|
||||
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
|
||||
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
|
||||
(on_artboard(pages, board), crop.then_some(board))
|
||||
(on_artboard(&symbols, pages, board), crop.then_some(board))
|
||||
};
|
||||
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
|
||||
let natural = clip.or_else(|| shown_bounds(&symbols, &nodes));
|
||||
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
|
||||
}
|
||||
|
||||
/// The placeable art of `layers` that lies on `board`.
|
||||
fn on_artboard(layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
|
||||
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
|
||||
/// The bounds of what `nodes` draw, as an export measures art
|
||||
/// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers
|
||||
/// left out, symbol instances measured by their art in `symbols`.
|
||||
pub(crate) fn shown_bounds(symbols: &SymbolBoxes, nodes: &[Node]) -> Option<Rect> {
|
||||
nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::encode::drawn_bounds(symbols, n)))
|
||||
}
|
||||
|
||||
/// The placeable art of `layers` that lies on `board`: each object whose drawn bounds
|
||||
/// ([`vectorcraft_render::encode::drawn_bounds`]) lie on it, or, for an object that draws nothing,
|
||||
/// each object whose visual bounds lie on it.
|
||||
fn on_artboard(symbols: &SymbolBoxes, layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
|
||||
let on_board = |c: &&Arc<Node>| {
|
||||
let b = vectorcraft_render::encode::drawn_bounds(symbols, c).or_else(|| c.visual_bounds_with(symbols));
|
||||
b.is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()))
|
||||
};
|
||||
art_of(layer_art(layers).filter(on_board))
|
||||
}
|
||||
|
||||
@@ -376,7 +389,7 @@ fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine
|
||||
return im.xf * full.inverse() * Affine::translate(centre - natural.center());
|
||||
}
|
||||
}
|
||||
let c = old.geometric_bounds().map_or(natural.center(), |b| b.center());
|
||||
let c = old.geometric_bounds_with(&d.symbol_boxes()).map_or(natural.center(), |b| b.center());
|
||||
Affine::translate(c - natural.center())
|
||||
}
|
||||
|
||||
@@ -411,7 +424,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Type reflows rather than scales: its frame takes the size it is placed at.
|
||||
let is_text = matches!(loaded.art, Art::Text(_));
|
||||
if is_text {
|
||||
let size = match (old_node.and_then(Node::geometric_bounds), rect) {
|
||||
let size = match (old_node.and_then(|o| o.geometric_bounds_with(&st.doc.symbol_boxes())), rect) {
|
||||
(Some(b), _) => b.size(),
|
||||
(None, Some(r)) => r.size(),
|
||||
(None, None) => text_frame(&st.doc, at).size(),
|
||||
@@ -436,7 +449,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings));
|
||||
let (id, size) = s.edit("Place", |d, sel| {
|
||||
let node = transformed(build(d, loaded, link), m);
|
||||
let size = node.geometric_bounds().unwrap_or_default();
|
||||
let size = node.geometric_bounds_with(&d.symbol_boxes()).unwrap_or_default();
|
||||
let (into, index) = match old {
|
||||
Some(old) => {
|
||||
let (par, idx, _) = d.position(old).ok_or(EngineError::NoNode(old))?;
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
//! superscript and subscript, baseline shift (`\up`, `\dn`) and the color table's color (`\cf`).
|
||||
//! - Paragraph formatting, reset by `\pard`: alignment (`\ql`, `\qc`, `\qr`, `\qj`, `\qd`), left,
|
||||
//! right and first-line indents, space before and after, and direction (`\rtlpar`, `\ltrpar`).
|
||||
//! `\ql` and `\qr` are the left and right edges in either direction, and a justified (`\qj`)
|
||||
//! paragraph marked right to left (`\rtlpar`) ends its last line on the right.
|
||||
//!
|
||||
//! Other control words are skipped, and so are hidden text (`\v`) and the groups of destinations
|
||||
//! that are not the document's text: every `\*` destination, the style sheet, document
|
||||
@@ -608,7 +610,11 @@ impl Reader {
|
||||
.paras
|
||||
.iter()
|
||||
.map(|p| ParaStyle {
|
||||
justify: p.justify,
|
||||
// `\qj` in an `\rtlpar` paragraph justifies it with its last line on the right.
|
||||
justify: match p.justify {
|
||||
Justify::JustifyLeft if p.rtl == Some(true) => Justify::JustifyRight,
|
||||
j => j,
|
||||
},
|
||||
left_indent: twips(p.left),
|
||||
right_indent: twips(p.right),
|
||||
first_line_indent: twips(p.first),
|
||||
@@ -682,6 +688,18 @@ mod tests {
|
||||
assert_eq!(s.paras.last().map(|p| p.left_indent), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_right_to_left_paragraphs_end_their_last_line_on_the_right() {
|
||||
let s = read(concat!(
|
||||
r"{\rtf1\pard\rtlpar\qj a\par\pard\qj\rtlpar b\par\pard\qj c\par\pard\rtlpar\ltrpar\qj d\par",
|
||||
r"\pard\rtlpar\ql e\par\pard\rtlpar\qr f\par\pard\rtlpar\qc g\par\pard\rtlpar\qd h}"
|
||||
));
|
||||
let justify: Vec<Justify> = s.paras.iter().map(|p| p.justify).collect();
|
||||
use Justify::*;
|
||||
assert_eq!(justify, [JustifyRight, JustifyRight, JustifyLeft, JustifyLeft, Left, Right, Center, JustifyAll]);
|
||||
assert_eq!(s.paras.first().and_then(|p| p.direction), Some(ParaDirection::RightToLeft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_and_stray_braces_end_cleanly() {
|
||||
let deep = format!("{{\\rtf1 {}x{}}}", "{".repeat(5000), "}".repeat(5000));
|
||||
|
||||
@@ -456,7 +456,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let look = GraphicStyle::of(String::new(), captured(n), n);
|
||||
let name = d.graphic_styles[i].name.clone();
|
||||
let chars = s.prefs.override_char_color;
|
||||
s.edit(&format!("Redefine Graphic Style \u{201c}{name}\u{201d}"), |d, _| {
|
||||
s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| {
|
||||
let id = d.graphic_style_id(i);
|
||||
let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look });
|
||||
// Objects still linked follow the new definition; edited ones are unlinked (they keep
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Color Group",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}",
|
||||
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), keepModel?: bool (as paint.setFill), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}",
|
||||
has_doc,
|
||||
swatch_new_group
|
||||
),
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Swatch Options",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{name, newName?, color? (e.g. {l, a, b} for a Lab colour), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
|
||||
"{name, newName?, color? (e.g. {l, a, b} for a Lab colour), keepModel?: bool (as paint.setFill; the swatch's own color and a color given with mode keep their model), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
|
||||
has_doc,
|
||||
swatch_edit
|
||||
),
|
||||
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Color Group",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
|
||||
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change; a color the swatch already has keeps its model), keepModel?: bool (as paint.setFill), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
|
||||
has_doc,
|
||||
swatch_edit_group
|
||||
),
|
||||
@@ -136,10 +136,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.new";
|
||||
let mode = str_param(p, "mode");
|
||||
let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) {
|
||||
(Some(cs), _) => {
|
||||
let cs: Vec<Paint> =
|
||||
cs.iter().map(|c| color_value(c).map(Paint::solid)).collect::<Option<_>>().ok_or_else(|| bad(C, "bad colour in `colors`"))?;
|
||||
let stored = |c: Color| if mode.is_some() { c } else { super::paint::new_color(s, p, c) };
|
||||
let cs: Vec<Paint> = cs
|
||||
.iter()
|
||||
.map(|c| color_value(c).map(|c| Paint::solid(stored(c))))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(C, "bad colour in `colors`"))?;
|
||||
if cs.is_empty() {
|
||||
return Err(bad(C, "`colors` is empty"));
|
||||
}
|
||||
@@ -148,7 +153,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)],
|
||||
(None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))),
|
||||
(None, None) => {
|
||||
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
|
||||
let paint = match mode {
|
||||
Some(_) => paint_from(s, &keeping_model(p))?,
|
||||
None => paint_from(s, p)?,
|
||||
}
|
||||
.unwrap_or_else(|| s.paint.fill.clone());
|
||||
// `tint` alone: that tint of the global colour the current fill links to.
|
||||
match (super::paint::tint_param(p, C)?, &paint) {
|
||||
(Some(t), Paint::Solid { swatch: Some(l), .. }) if p.get("swatch").is_none() => {
|
||||
@@ -161,7 +170,6 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let spot = bool_or(p, "spot", false);
|
||||
let global = spot || bool_or(p, "global", false);
|
||||
let mode = str_param(p, "mode");
|
||||
let group = str_param(p, "group");
|
||||
let paints = paints
|
||||
.into_iter()
|
||||
@@ -294,7 +302,11 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u
|
||||
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.newGroup";
|
||||
let mut names = str_list(p, "swatches");
|
||||
let colors: Vec<Color> = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default();
|
||||
let colors: Vec<Color> = p
|
||||
.get("colors")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(color_value).map(|c| super::paint::new_color(s, p, c)).collect())
|
||||
.unwrap_or_default();
|
||||
let from_art = bool_or(p, "fromArtwork", false);
|
||||
let to_global = from_art && bool_or(p, "toGlobal", true);
|
||||
let tints = bool_or(p, "includeTints", false);
|
||||
@@ -448,6 +460,16 @@ fn convert_to_mode(c: Color, mode: &str, cmd: &str) -> Result<Color> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Paint params `p` with `keepModel: true`, for a color given with a `mode`: [`convert_to_mode`]
|
||||
/// converts it, and the document's color mode does not convert it first.
|
||||
fn keeping_model(p: &Value) -> Value {
|
||||
let mut q = p.clone();
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.insert("keepModel".into(), Value::Bool(true));
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// How a swatch edit reaches the solid paints linked to swatch `from`.
|
||||
struct Relink {
|
||||
from: String,
|
||||
@@ -518,7 +540,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
|
||||
let paint = match p.get("paint").filter(|v| !v.is_null()) {
|
||||
Some(v) => match (paint_from(s, v)?, &sw.paint) {
|
||||
Some(v) => match (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) {
|
||||
(Some(Paint::Solid { color, .. }), Paint::Solid { .. }) => Some(Paint::solid(color)),
|
||||
(Some(Paint::Gradient(g)), Paint::Gradient(_)) => {
|
||||
Some(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { geom: None, swatch: None, ..*g })))
|
||||
@@ -529,7 +551,11 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => None,
|
||||
};
|
||||
let mut color = match p.get("color").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?),
|
||||
Some(v) => {
|
||||
let c = color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?;
|
||||
// The swatch's own color, and a color given with a mode, keep their model.
|
||||
Some(if given("mode") || solid == Some(c) { c } else { super::paint::new_color(s, p, c) })
|
||||
}
|
||||
None => paint.as_ref().and_then(Paint::color).or(solid),
|
||||
};
|
||||
if let (Some(c), Some(m)) = (color, str_param(p, "mode")) {
|
||||
@@ -849,6 +875,11 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}"))))
|
||||
.collect::<Result<_>>()?;
|
||||
// A color its swatch already has keeps its model. The others take the document's.
|
||||
let old: Vec<Option<Color>> =
|
||||
s.doc()?.doc.swatch_groups.iter().find(|g| g.name == group).map(|g| g.swatches.iter().map(|w| w.paint.color()).collect()).unwrap_or_default();
|
||||
let colors: Vec<Color> =
|
||||
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
|
||||
let rename = str_param(p, "rename");
|
||||
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
|
||||
e.defaults(s);
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -421,6 +421,16 @@ impl CharChange {
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// The `fill` and `stroke` colors as the document stores them: an RGB color in a CMYK
|
||||
/// document becomes CMYK through the color settings ([`super::paint::new_color`]).
|
||||
fn store_colors(&mut self, s: &Session, p: &Value) {
|
||||
for paint in [&mut self.fill, &mut self.stroke].into_iter().flatten() {
|
||||
if let Paint::Solid { color, .. } = paint {
|
||||
*color = super::paint::new_color(s, p, *color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.font.is_none()
|
||||
&& self.style.is_none()
|
||||
@@ -600,6 +610,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
};
|
||||
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
change.store_colors(s, p);
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
|
||||
@@ -164,12 +164,13 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
}
|
||||
|
||||
/// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's
|
||||
/// JSON leaves out at their defaults (the Hyphenation options, the Every-line Composer, no tab
|
||||
/// JSON leaves out at their defaults (the Hyphenation and Justification options, the Every-line Composer, no tab
|
||||
/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters.
|
||||
pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
for (k, x) in [
|
||||
("hyphenation", json!(v.hyphenation)),
|
||||
("justification", json!(v.justification)),
|
||||
("composer", json!(v.composer)),
|
||||
("tabs", json!(v.tabs)),
|
||||
("mojikumi", json!(v.mojikumi)),
|
||||
@@ -442,7 +443,7 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
return Err(bad(&c, format!("a style named `{name}` already exists")));
|
||||
}
|
||||
let n2 = name.clone();
|
||||
s.edit(&format!("New {base}"), |d, _| {
|
||||
s.edit(if kind == Kind::Char { "New Character Style" } else { "New Paragraph Style" }, |d, _| {
|
||||
kind.defs_mut(d).push(TextStyleDef { name: n2, attrs });
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -478,7 +479,7 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
Ok(st)
|
||||
};
|
||||
let count = ids.len();
|
||||
s.edit(&format!("Apply {name}"), |d, _| {
|
||||
s.edit_named("Apply {name}", &name, name == kind.normal(), |d, _| {
|
||||
for id in &ids {
|
||||
let Some(t) = text_mut(d, *id) else { continue };
|
||||
match kind {
|
||||
@@ -528,7 +529,7 @@ fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map<String, V
|
||||
let d = &s.doc()?.doc;
|
||||
let old = kind.attrs(d, name).ok_or_else(|| bad(&kind.cmd(verb), format!("no style `{name}`")))?;
|
||||
let name = name.to_string();
|
||||
s.edit(&format!("Redefine {name}"), |d, _| {
|
||||
s.edit_named("Redefine {name}", &name, name == kind.normal(), |d, _| {
|
||||
match kind.defs_mut(d).iter_mut().find(|s| s.name == name) {
|
||||
Some(def) => def.attrs = new_attrs.clone(),
|
||||
None => kind.defs_mut(d).push(TextStyleDef { name: name.clone(), attrs: new_attrs.clone() }),
|
||||
@@ -664,6 +665,37 @@ mod tests {
|
||||
assert_eq!(list["styles"][1]["uses"], 1);
|
||||
}
|
||||
|
||||
/// Applying and redefining a style record a step named from a template with the style's name
|
||||
/// in it (`HistoryEntry::named`), and undo and redo keep it. The Normal styles' names are
|
||||
/// marked as names VectorCraft gives. A new style's step has a name of its own.
|
||||
#[test]
|
||||
fn style_steps_are_named_from_a_template() {
|
||||
let (mut s, id) = session_with_text("Hello");
|
||||
let last = |s: &Session| {
|
||||
let e = s.doc().unwrap().history.undo.last().unwrap();
|
||||
(e.label.clone(), e.named().map(|n| (n.template, n.name.clone(), n.built_in)))
|
||||
};
|
||||
let named = |template, name: &str, built_in| Some((template, name.to_string(), built_in));
|
||||
s.execute("charStyle.new", &json!({"name": "Body"})).unwrap();
|
||||
assert_eq!(last(&s), ("New Character Style".into(), None));
|
||||
s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap();
|
||||
assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let redo = s.doc().unwrap().history.redo.last().unwrap().named().cloned();
|
||||
assert_eq!(redo.map(|n| n.label()).as_deref(), Some("Apply Body"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false)));
|
||||
s.execute("charStyle.apply", &json!({"name": NORMAL_CHAR, "id": id})).unwrap();
|
||||
assert_eq!(last(&s), (format!("Apply {NORMAL_CHAR}"), named("Apply {name}", NORMAL_CHAR, true)));
|
||||
s.execute("paraStyle.new", &json!({"name": "Lead"})).unwrap();
|
||||
assert_eq!(last(&s), ("New Paragraph Style".into(), None));
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Lead", "attrs": {"direction": "rightToLeft"}})).unwrap();
|
||||
assert_eq!(last(&s), ("Redefine Lead".into(), named("Redefine {name}", "Lead", false)));
|
||||
// A step renamed since it was recorded is no longer named from the template.
|
||||
s.doc_mut().unwrap().history.undo.last_mut().unwrap().label = "Typing".into();
|
||||
assert_eq!(last(&s), ("Typing".into(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_from_selection_rename_delete_and_undo() {
|
||||
let (mut s, id) = session_with_text("Title");
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -547,7 +547,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fill = match p.get("fill") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(vectorcraft_color::Paint::None),
|
||||
Some(v) => Some(vectorcraft_color::Paint::solid(color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?)),
|
||||
Some(v) => Some(vectorcraft_color::Paint::solid(super::paint::new_color(s, p, color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?))),
|
||||
};
|
||||
let features = super::textedit::features_param(p, C)?;
|
||||
if font.is_none()
|
||||
@@ -1098,6 +1098,61 @@ mod area_tests {
|
||||
assert!(text(&s).para_at(1).roman_hanging);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and
|
||||
/// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their
|
||||
/// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They
|
||||
/// are saved only when not the defaults, and paragraph styles carry them.
|
||||
#[test]
|
||||
fn justification_options_are_set_per_paragraph_and_saved_only_when_not_default() {
|
||||
use vectorcraft_doc::{JustificationOptions, SingleWordJustify};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert_eq!(paras(&s).0.justification, JustificationOptions::default());
|
||||
assert!(serde_json::to_value(paras(&s).0).unwrap().get("justification").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute(
|
||||
"text.setFormat",
|
||||
&json!({
|
||||
"wordSpacing": [60, 90, 2000], "letterSpacing": {"max": 10}, "glyphScaling": [95, 100, 105],
|
||||
"autoLeading": 150, "singleWordJustification": "alignCenter", "start": 5, "end": 5
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let want = JustificationOptions {
|
||||
word_spacing: [60.0, 90.0, 1000.0],
|
||||
letter_spacing: [0.0, 0.0, 10.0],
|
||||
glyph_scaling: [95.0, 100.0, 105.0],
|
||||
auto_leading: 150.0,
|
||||
single_word: SingleWordJustify::AlignCenter,
|
||||
};
|
||||
let (first, second) = paras(&s);
|
||||
assert_eq!((first.justification, &second.justification), (JustificationOptions::default(), &want), "only the paragraph the caret is in");
|
||||
assert_eq!(serde_json::to_value(&second).unwrap()["justification"]["single_word"], "alignCenter");
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap();
|
||||
assert_eq!(back.justification, want);
|
||||
// Out of order: nothing changes, and the error names the setting.
|
||||
let e = s.execute("text.setFormat", &json!({"wordSpacing": {"desired": 85}, "letterSpacing": {"min": 5}})).unwrap_err().to_string();
|
||||
assert!(e.contains("letterSpacing"), "{e}");
|
||||
assert_eq!(paras(&s).1.justification, want);
|
||||
assert!(s.execute("text.setFormat", &json!({"glyphScaling": [100, 100]})).is_err());
|
||||
assert!(s.execute("text.setFormat", &json!({"singleWordJustification": "spread"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(paras(&s).1.justification, JustificationOptions::default());
|
||||
// A paragraph style carries them; an older file's paragraph reads the defaults.
|
||||
s.execute("paraStyle.new", &json!({"name": "Airy", "attrs": {"justification": {"auto_leading": 200.0}}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Airy", "id": id})).unwrap();
|
||||
assert_eq!(paras(&s).0.justification, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() });
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap();
|
||||
assert_eq!(old.justification, JustificationOptions::default());
|
||||
}
|
||||
|
||||
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
|
||||
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
|
||||
/// carry them; params override; the values are journaled; imported text keeps the Roman
|
||||
|
||||
@@ -91,6 +91,27 @@ pub struct HistoryEntry {
|
||||
next_bytes: usize,
|
||||
/// Which document the figures were taken against (its address): the next state.
|
||||
next: usize,
|
||||
/// The template and name `label` was made from ([`Session::edit_named`]).
|
||||
named: Option<NamedStep>,
|
||||
}
|
||||
|
||||
/// A step name made by filling the `{name}` placeholder of a template with a name
|
||||
/// ([`Session::edit_named`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NamedStep {
|
||||
/// The English template, with one `{name}` placeholder (`Apply {name}`).
|
||||
pub template: &'static str,
|
||||
/// The name in the placeholder, as the document holds it (a style's name).
|
||||
pub name: String,
|
||||
/// The name is one VectorCraft gives (`[Normal Character Style]`), not one the user typed.
|
||||
pub built_in: bool,
|
||||
}
|
||||
|
||||
impl NamedStep {
|
||||
/// The step name: the template with the name in its placeholder.
|
||||
pub fn label(&self) -> String {
|
||||
self.template.replace("{name}", &self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
@@ -98,7 +119,14 @@ impl HistoryEntry {
|
||||
pub(crate) fn kept(label: String, doc: Arc<Document>, selection: Arc<Selection>, next: &Document) -> Self {
|
||||
doc.drop_lookup_cache();
|
||||
let (bytes, next_bytes) = doc.retained_bytes_each(next);
|
||||
HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize }
|
||||
HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None }
|
||||
}
|
||||
|
||||
/// The template and name the step's label was made from, while the label is still the one
|
||||
/// they make: `None` for a step named any other way, and for one whose label was changed
|
||||
/// since it was recorded.
|
||||
pub fn named(&self) -> Option<&NamedStep> {
|
||||
self.named.as_ref().filter(|n| n.label() == self.label)
|
||||
}
|
||||
|
||||
/// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the
|
||||
@@ -112,7 +140,7 @@ impl HistoryEntry {
|
||||
return Self::kept(label, doc, selection, now);
|
||||
}
|
||||
doc.drop_lookup_cache();
|
||||
HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize }
|
||||
HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize, named: None }
|
||||
}
|
||||
|
||||
/// The figures [`HistoryEntry::turned`] reuses.
|
||||
@@ -1739,6 +1767,33 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Session::edit`] with the step named `template`, its `{name}` placeholder filled with `name`
|
||||
/// (a style's name). The undo step it records keeps the template and the name
|
||||
/// ([`HistoryEntry::named`]), and a frontend shows the template's translation with the name in
|
||||
/// it. An edit that records no step of its own (inside an interaction, or a later edit of an
|
||||
/// undo group) keeps neither, and neither does a step an undo group names.
|
||||
pub fn edit_named<T>(
|
||||
&mut self,
|
||||
template: &'static str,
|
||||
name: &str,
|
||||
built_in: bool,
|
||||
f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let named = NamedStep { template, name: name.to_string(), built_in };
|
||||
let step = named.label();
|
||||
// The step this edit records keeps the document as it is now.
|
||||
let now = Arc::as_ptr(&self.doc()?.doc);
|
||||
let v = self.edit(&step, f)?;
|
||||
if let Ok(st) = self.doc_mut()
|
||||
&& let Some(e) = st.history.undo.last_mut()
|
||||
&& Arc::as_ptr(&e.doc) == now
|
||||
&& e.label == step
|
||||
{
|
||||
e.named = Some(named);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Change only the selection (not an undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
|
||||
@@ -28,6 +28,61 @@ fn create_and_undo_redo() {
|
||||
assert!(s.execute("edit.redo", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Clear History removes the steps to undo and the steps to redo. The document and the selection
|
||||
/// stay as they are, Undo and Redo are then disabled, and clearing is not a step itself.
|
||||
#[test]
|
||||
fn clear_history_removes_every_step_and_keeps_the_document() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
||||
let b = rect(&mut s, 200.0, 10.0, 100.0, 50.0);
|
||||
rect(&mut s, 400.0, 10.0, 100.0, 50.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len()), (2, 1));
|
||||
let (doc, selection, dirty) = (st.doc.clone(), st.selection.clone(), st.is_dirty());
|
||||
assert_eq!(s.execute("edit.clearHistory", &json!({})).unwrap(), json!({"cleared": 3}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len()), (0, 0));
|
||||
assert!(Arc::ptr_eq(&st.doc, &doc), "the document is the one from before clearing");
|
||||
assert_eq!((&st.selection, st.is_dirty()), (&selection, dirty));
|
||||
assert!(st.doc.node(a).is_some() && st.doc.node(b).is_some());
|
||||
for cmd in ["edit.undo", "edit.redo", "edit.clearHistory"] {
|
||||
assert!(matches!(s.execute(cmd, &json!({})), Err(EngineError::Disabled(..))), "{cmd}");
|
||||
}
|
||||
// A new edit is a step again: undoing it goes back to the document the history was cleared at.
|
||||
rect(&mut s, 600.0, 10.0, 100.0, 50.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &doc));
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Clear History is disabled without a document and in a document with no step to undo or redo.
|
||||
#[test]
|
||||
fn clear_history_needs_a_step_to_clear() {
|
||||
let mut s = Session::new();
|
||||
assert!(matches!(s.execute("edit.clearHistory", &json!({})), Err(EngineError::Disabled(..))), "no document");
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
match s.execute("edit.clearHistory", &json!({})) {
|
||||
Err(EngineError::Disabled(_, why)) => assert_eq!(why, "no history to clear"),
|
||||
other => panic!("a new document: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag still in progress when the history is cleared ends, and its change stays in the document
|
||||
/// without a step to undo it.
|
||||
#[test]
|
||||
fn clear_history_keeps_a_drag_in_progress_in_the_document() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
||||
s.begin_interaction("Move").unwrap();
|
||||
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
|
||||
assert_eq!(s.execute("edit.clearHistory", &json!({})).unwrap(), json!({"cleared": 2}));
|
||||
assert!(!s.in_interaction());
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.node(a).unwrap().geometric_bounds().unwrap().x0, 40.0);
|
||||
assert!(st.history.undo.is_empty() && st.history.redo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_disabled() {
|
||||
let mut s = Session::new();
|
||||
@@ -1729,6 +1784,24 @@ fn bounding_box_handles_snap_to_the_grid() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Shift-constrained move with Snap to Grid on stays level: the square's center lands on a
|
||||
/// gridline across, and its top stays where it was, off the grid.
|
||||
#[test]
|
||||
fn a_shift_move_snapped_to_the_grid_stays_on_its_line() {
|
||||
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, ..ViewInfo::default() };
|
||||
let shift = vectorcraft_tools::Mods { shift: true, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (kind, x, y) in [(PointerKind::Down, 151.0, 151.0), (PointerKind::Drag, 180.0, 153.0), (PointerKind::Up, 180.0, 153.0)] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(shift), v).unwrap();
|
||||
}
|
||||
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
|
||||
assert!(on_grid(b.center().x) && (b.y0 - 101.0).abs() < 1e-9, "{b:?}, grid step {step}");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -655,6 +655,20 @@ fn symbolism_adjustments() {
|
||||
assert!(s.execute("symbol.adjust", &json!({"tool": "nope", "points": at})).is_err());
|
||||
}
|
||||
|
||||
/// In a CMYK document the Symbol Stainer's RGB `color` separates through the color settings, as
|
||||
/// `paint.setFill` stores it.
|
||||
#[test]
|
||||
fn symbol_stainer_rgb_color_in_a_cmyk_document_separates_through_the_color_settings() {
|
||||
use vectorcraft_color::{Color, Paint, cms};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
|
||||
let (inst, _, b) = make_symbol(&mut s);
|
||||
let c = b.center();
|
||||
s.execute("symbol.adjust", &json!({"tool": "stain", "points": [[c.x, c.y]], "color": "#ff0000", "intensity": 10})).unwrap();
|
||||
let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent);
|
||||
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_sprayer_tool_gesture_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -103,4 +103,9 @@ fn add_to_swatches_makes_a_colour_group_in_one_step() {
|
||||
// Again: a second group with a free name.
|
||||
assert_eq!(run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}))["name"], "Tide 2");
|
||||
assert!(s.execute("colorTheme.addToSwatches", &json!({"name": "Nope"})).is_err());
|
||||
// In a CMYK document the theme's colors keep their model.
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
|
||||
run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}));
|
||||
let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Tide").expect("the group");
|
||||
assert_eq!(g.swatches.iter().filter_map(|w| w.paint.color()).collect::<Vec<_>>(), s.prefs.color_themes[0].colors);
|
||||
}
|
||||
@@ -849,6 +849,33 @@ fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_curve_whose_handles_are_near_1e308_is_refused() {
|
||||
// The canvas check measures a selected curve whose handles are near ±1e308 by its control
|
||||
// points, so `path.create` refuses such a curve, given as anchors or as path data: the document
|
||||
// stays as it was, with no undo step.
|
||||
let mut s = session();
|
||||
curve(&mut s);
|
||||
let h = 1e308;
|
||||
for p in [
|
||||
json!({"anchors": [{"x": 100, "y": 100, "out": [150, h]}, {"x": 300, "y": 100, "in": [250, h]}]}),
|
||||
json!({"anchors": [{"x": 100, "y": 100, "out": [150, -h]}, {"x": 300, "y": 100, "in": [250, h]}]}),
|
||||
json!({"anchors": [{"x": 100, "y": 100, "out": [1.7e308, 100]}, {"x": 300, "y": 100}]}),
|
||||
json!({"d": "M100 100 C150 1e308 250 1e308 300 100"}),
|
||||
] {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let e = s.execute("path.create", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("exceed the canvas"), "{p}: {e}");
|
||||
assert!(s.doc().unwrap().doc == before, "{p} changed the document");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{p} left an undo step");
|
||||
}
|
||||
// A curve whose handles stay on the canvas is kept.
|
||||
let p = json!({"anchors": [{"x": 100, "y": 100, "out": [150, 3e6]}, {"x": 300, "y": 100, "in": [250, 3e6]}]});
|
||||
let id = NodeId(s.execute("path.create", &p).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(path(&s, id).bounds().is_some_and(|b| b.y1 > 2e6));
|
||||
}
|
||||
|
||||
/// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100).
|
||||
fn curve(s: &mut Session) -> NodeId {
|
||||
let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]);
|
||||
|
||||
@@ -250,6 +250,130 @@ fn rgb_colors_given_to_a_cmyk_document_separate_through_the_color_settings() {
|
||||
assert_eq!(node(&s, a).appearance.fill_paint().color(), Some(blue));
|
||||
}
|
||||
|
||||
/// A new CMYK document.
|
||||
fn cmyk_session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600, "colorMode": "cmyk"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// RGB colors given to the type commands in a CMYK document separate through the color settings,
|
||||
/// as `paint.setFill` stores them: `text.create {color}`, `text.setStyle {fill}` and
|
||||
/// `text.setRangeStyle {fill, stroke}`. CMYK colors are stored as given.
|
||||
#[test]
|
||||
fn rgb_type_colors_given_to_a_cmyk_document_separate_through_the_color_settings() {
|
||||
let mut s = cmyk_session();
|
||||
let blue = managed_cmyk(Color::rgb8(83, 170, 223));
|
||||
let t = id_of(s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hello", "color": "#53aadf"})).unwrap());
|
||||
let runs = |s: &Session| match node(s, t).kind {
|
||||
NodeKind::Text(o) => o.runs.iter().map(|r| (r.style.fill.color(), r.style.stroke.color())).collect::<Vec<_>>(),
|
||||
k => panic!("not type: {k:?}"),
|
||||
};
|
||||
assert_eq!(runs(&s)[0].0, Some(blue));
|
||||
s.execute("text.setStyle", &json!({"ids": [t.0], "fill": "#ff0000"})).unwrap();
|
||||
assert_eq!(runs(&s)[0].0, Some(managed_cmyk(Color::rgb8(255, 0, 0))));
|
||||
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 0, "end": 2, "fill": "#53aadf", "stroke": "#53aadf"})).unwrap();
|
||||
assert_eq!(runs(&s)[0], (Some(blue), Some(blue)));
|
||||
s.execute("text.setStyle", &json!({"ids": [t.0], "fill": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.0 == Some(Color::cmyk(0.1, 0.2, 0.3, 0.4))), "{:?}", runs(&s));
|
||||
s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 0, "end": 2, "stroke": "#53aadf", "keepModel": true})).unwrap();
|
||||
assert_eq!(runs(&s)[0].1, Some(Color::rgb8(83, 170, 223)), "keepModel keeps RGB");
|
||||
}
|
||||
|
||||
/// `shapeBuilder.merge {fill: "#rrggbb"}` in a CMYK document fills the merged shape as
|
||||
/// `fill: {color}` does.
|
||||
#[test]
|
||||
fn a_shape_builder_hex_fill_in_a_cmyk_document_separates_through_the_color_settings() {
|
||||
let mut s = cmyk_session();
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0));
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"ids": [a.0, b.0], "points": [[20, 30], [80, 30]], "fill": "#53aadf"})).unwrap();
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert_eq!(node(&s, merged).appearance.fill_paint().color(), Some(managed_cmyk(Color::rgb8(83, 170, 223))));
|
||||
// The paint form takes keepModel.
|
||||
let (a, b) = (rect(&mut s, 200.0), rect(&mut s, 230.0));
|
||||
let fill = json!({"color": "#53aadf", "keepModel": true});
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"ids": [a.0, b.0], "points": [[210, 30], [270, 30]], "fill": fill})).unwrap();
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert_eq!(node(&s, merged).appearance.fill_paint().color(), Some(Color::rgb8(83, 170, 223)));
|
||||
}
|
||||
|
||||
/// RGB colors given to gradient stops, freeform gradient points and gradient mesh points in a CMYK
|
||||
/// document separate through the color settings: `paint.editGradient {stops}`, a freeform
|
||||
/// `gradient` given to `paint.setFill`, `paint.freeform.addPoint` and `setPoint`, and
|
||||
/// `object.mesh.setPointColor` and `addLine`. The Mesh tool adds a line in the fill's own model.
|
||||
#[test]
|
||||
fn rgb_gradient_and_mesh_colors_given_to_a_cmyk_document_separate_through_the_color_settings() {
|
||||
let mut s = cmyk_session();
|
||||
let (blue, red) = (managed_cmyk(Color::rgb8(83, 170, 223)), managed_cmyk(Color::rgb8(255, 0, 0)));
|
||||
let a = rect(&mut s, 10.0);
|
||||
let gradient = |s: &Session| match node(s, a).appearance.fill_paint() {
|
||||
Paint::Gradient(g) => *g,
|
||||
p => panic!("not a gradient: {p:?}"),
|
||||
};
|
||||
let stops = |s: &Session| gradient(s).gradient.stops.iter().map(|st| st.color).collect::<Vec<_>>();
|
||||
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": {"gray": 0}}, {"offset": 1, "color": "#53aadf"}]})).unwrap();
|
||||
assert_eq!(stops(&s), vec![Color::gray(0.0), blue]);
|
||||
// keepModel keeps RGB, and stops sent back with only their offsets changed keep their colors.
|
||||
let rgb = [Color::rgb8(255, 0, 0), Color::rgb8(83, 170, 223)];
|
||||
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#53aadf"}], "keepModel": true}))
|
||||
.unwrap();
|
||||
assert_eq!(stops(&s), rgb);
|
||||
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0.2, "color": "#ff0000"}, {"offset": 1, "color": "#53aadf"}]})).unwrap();
|
||||
assert_eq!(stops(&s), rgb);
|
||||
// A stop naming an RGB process swatch takes the swatch's color as it is.
|
||||
let orange = Color::rgb8(255, 128, 0);
|
||||
s.execute("swatch.new", &json!({"name": "Screen", "color": "#ff8000", "keepModel": true})).unwrap();
|
||||
let named = json!([{"offset": 0, "swatch": "Screen"}, {"offset": 1, "color": "#53aadf"}]);
|
||||
s.execute("paint.editGradient", &json!({"stops": named})).unwrap();
|
||||
assert_eq!(stops(&s), [orange, rgb[1]], "the RGB blue the gradient has stays RGB");
|
||||
s.execute("paint.setFill", &json!({"gradient": {"stops": named}})).unwrap();
|
||||
assert_eq!(stops(&s), [orange, blue]);
|
||||
let magenta = Color::cmyk(0.0, 1.0, 0.0, 0.0);
|
||||
let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": {"c": 0, "m": 100, "y": 0, "k": 0}}]);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
|
||||
let colors = |s: &Session| gradient(s).freeform.map(|f| f.points.iter().map(|p| p.color).collect::<Vec<_>>()).unwrap_or_default();
|
||||
assert_eq!(colors(&s), vec![blue, magenta]);
|
||||
let i = s.execute("paint.freeform.addPoint", &json!({"at": [40, 20], "color": "#ff0000"})).unwrap()["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(colors(&s)[i], red);
|
||||
s.execute("paint.freeform.setPoint", &json!({"index": 1, "color": "#53aadf"})).unwrap();
|
||||
assert_eq!(colors(&s)[1], blue);
|
||||
// New stops recolor the three points from their CMYK values.
|
||||
s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]})).unwrap();
|
||||
let stored = vectorcraft_color::Gradient {
|
||||
kind: Default::default(),
|
||||
stops: vec![vectorcraft_color::GradientStop::new(0.0, managed_cmyk(Color::WHITE)), vectorcraft_color::GradientStop::new(1.0, red)],
|
||||
};
|
||||
assert_eq!(colors(&s), [0.0, 0.5, 1.0].map(|t| stored.sample(t).0), "{:?}", colors(&s));
|
||||
|
||||
let m = rect(&mut s, 300.0);
|
||||
s.execute("object.mesh.create", &json!({"ids": [m.0], "rows": 1, "cols": 1})).unwrap();
|
||||
let mesh = |s: &Session| match node(s, m).kind {
|
||||
NodeKind::Mesh(g) => g.points.iter().map(|p| p.color).collect::<Vec<_>>(),
|
||||
k => panic!("not a mesh: {k:?}"),
|
||||
};
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 1, "color": "#53aadf", "keepModel": true})).unwrap();
|
||||
assert_eq!(mesh(&s)[1], Color::rgb8(83, 170, 223), "keepModel keeps RGB");
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 0, "color": "#53aadf"})).unwrap();
|
||||
assert_eq!(mesh(&s)[0], blue);
|
||||
let i = s.execute("object.mesh.addLine", &json!({"id": m.0, "x": 320, "y": 30, "color": "#ff0000"})).unwrap()["index"].as_u64().unwrap() as usize;
|
||||
assert_eq!(mesh(&s)[i], red);
|
||||
let cyan = Color::cmyk(0.5, 0.2, 0.0, 0.0);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"c": 50, "m": 20, "y": 0, "k": 0}})).unwrap();
|
||||
let before = mesh(&s).len();
|
||||
s.select_tool("mesh", ViewInfo::default()).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 335.0, 45.0), ViewInfo::default()).unwrap();
|
||||
let after = mesh(&s);
|
||||
assert!(after.len() > before, "the click adds a line");
|
||||
assert!(after.contains(&cyan), "{after:?}");
|
||||
// An RGB fill kept with keepModel stays RGB on the new point.
|
||||
let orange = Color::rgb8(255, 128, 0);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff8000", "keepModel": true})).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 340.0, 20.0), ViewInfo::default()).unwrap();
|
||||
assert!(mesh(&s).contains(&orange), "{:?}", mesh(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_options_are_a_preference() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -141,6 +141,78 @@ fn an_exported_pdf_opens_with_its_file_info() {
|
||||
assert_eq!(back["title"], "poster.pdf", "the title stays the file name");
|
||||
}
|
||||
|
||||
/// Unix seconds of 2020-01-01 00:00:00 UTC.
|
||||
const CREATED: i64 = 1_577_836_800;
|
||||
|
||||
/// A session whose document was created at `created` (Unix seconds, or `null` for none).
|
||||
fn created_at(created: Value) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80, "created": created})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The PDF date the writer gives Unix seconds `t` (UTC).
|
||||
fn pdf_date(t: i64) -> String {
|
||||
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
|
||||
format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)")
|
||||
}
|
||||
|
||||
/// Check that a PDF or PDF-compatible `.ai` was created at a time in `created` and modified at a
|
||||
/// time in `modified`: in the XMP packet (`vectorcraft_pdf::file_info`) and in the document
|
||||
/// information dictionary.
|
||||
fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive<i64>, modified: std::ops::RangeInclusive<i64>) {
|
||||
let xmp = vectorcraft_pdf::file_info(bytes, None).expect("a PDF");
|
||||
assert!(xmp.created.is_some_and(|t| created.contains(&t)), "xmp:CreateDate {:?} in {created:?}", xmp.created);
|
||||
assert!(xmp.modified.is_some_and(|t| modified.contains(&t)), "xmp:ModifyDate {:?} in {modified:?}", xmp.modified);
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let trailer = &text[text.rfind("trailer").expect("a trailer")..];
|
||||
let n = trailer.split("/Info ").nth(1).and_then(|r| r.split(' ').next()).expect("an /Info entry");
|
||||
let info = &text[text.find(&format!("\n{n} 0 obj")).expect("the document information")..];
|
||||
let info = &info[..info.find("endobj").expect("its end")];
|
||||
assert!(created.clone().any(|t| info.contains(&format!("/CreationDate{}", pdf_date(t)))), "/CreationDate in {created:?}: {info}");
|
||||
assert!(modified.clone().any(|t| info.contains(&format!("/ModDate{}", pdf_date(t)))), "/ModDate in {modified:?}: {info}");
|
||||
}
|
||||
|
||||
/// Run `f` → (its result, the time of writing: the Unix seconds before and after it).
|
||||
fn written<T>(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive<i64>) {
|
||||
let before = vectorcraft_doc::metadata::now_unix().unwrap();
|
||||
let out = f();
|
||||
(out, before..=vectorcraft_doc::metadata::now_unix().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exported_pdf_is_created_with_the_document_and_modified_when_it_is_written() {
|
||||
let mut s = created_at(json!(CREATED));
|
||||
for p in [json!({}), json!({"preset": "PDF/X-4:2010"})] {
|
||||
let (pdf, now) = written(|| b64(&s.execute("document.exportPdf", &p).unwrap()));
|
||||
assert_dated(&pdf, CREATED..=CREATED, now);
|
||||
}
|
||||
let (pdf, now) = written(|| b64(&s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap()));
|
||||
assert_dated(&pdf, CREATED..=CREATED, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_compatible_ai_is_created_with_the_document_and_modified_when_it_is_written() {
|
||||
let mut s = created_at(json!(CREATED));
|
||||
let path = std::env::temp_dir().join(format!("vc-fileinfo-created-{}.ai", std::process::id()));
|
||||
let (saved, now) = written(|| s.execute("document.save", &json!({"path": path.to_string_lossy()})));
|
||||
saved.unwrap();
|
||||
let ai = std::fs::read(&path).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_dated(&ai, CREATED..=CREATED, now);
|
||||
assert_eq!(s.doc().unwrap().doc.metadata.created, Some(CREATED), "the save keeps the creation date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_of_a_document_without_a_creation_date_is_dated_when_it_is_written() {
|
||||
let mut s = created_at(Value::Null);
|
||||
assert_eq!(s.doc().unwrap().doc.metadata.created, None);
|
||||
let (pdf, now) = written(|| b64(&s.execute("document.exportPdf", &json!({})).unwrap()));
|
||||
assert_dated(&pdf, now.clone(), now);
|
||||
let xmp = vectorcraft_pdf::file_info(&pdf, None).unwrap();
|
||||
assert_eq!(xmp.created, xmp.modified, "created and modified at the same time of writing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
||||
|
||||
@@ -84,6 +84,57 @@ fn new_stops_recolour_the_points() {
|
||||
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ff0000".into(), "#0000ff".into()));
|
||||
}
|
||||
|
||||
/// A CMYK color's inks.
|
||||
fn inks(c: Color) -> [f32; 4] {
|
||||
match c {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
c => panic!("a CMYK color expected: {c:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cyan and black inks only, adding up to one.
|
||||
fn cyan_and_black(c: Color) -> bool {
|
||||
let [c, m, y, k] = inks(c);
|
||||
m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_stops_recolor_the_points_with_inks() {
|
||||
let cyan_to_black = json!([{"offset": 0, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 1}}]);
|
||||
let (mut s, id) = freeform_rect();
|
||||
run(&mut s, "paint.editGradient", json!({"stops": cyan_to_black}));
|
||||
let f = points(&s, id);
|
||||
let n = f.points.len();
|
||||
for (i, p) in f.points.iter().enumerate() {
|
||||
let k = i as f32 / (n - 1) as f32;
|
||||
let [c, m, y, kk] = inks(p.color);
|
||||
assert!((c - (1.0 - k)).abs() < 1e-5 && m.abs() < 1e-5 && y.abs() < 1e-5 && (kk - k).abs() < 1e-5, "point {i}: {:?}", p.color);
|
||||
}
|
||||
// A CMYK gradient switched to freeform places points with inks.
|
||||
let mut s = session();
|
||||
let id = shape(&mut s, "shape.rectangle", 100.0, 100.0, 100.0, 100.0);
|
||||
run(&mut s, "paint.setFill", json!({"gradient": {"kind": "linear", "stops": cyan_to_black}}));
|
||||
run(&mut s, "paint.editGradient", json!({"kind": "freeform"}));
|
||||
assert!(points(&s, id).points.iter().all(|p| cyan_and_black(p.color)), "{:?}", points(&s, id).points);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_added_between_cmyk_points_take_inks() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
let n = points(&s, id).points.len();
|
||||
for i in 0..n {
|
||||
let c = if i % 2 == 0 { json!({"c": 1, "m": 0, "y": 0, "k": 0}) } else { json!({"c": 0, "m": 0, "y": 0, "k": 1}) };
|
||||
run(&mut s, "paint.freeform.setPoint", json!({"index": i, "color": c}));
|
||||
}
|
||||
run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]}));
|
||||
let i = run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 0}))["index"].as_u64().unwrap() as usize;
|
||||
let split = points(&s, id).points[i].color;
|
||||
assert!(cyan_and_black(split), "the split point: {split:?}");
|
||||
let i = run(&mut s, "paint.freeform.addPoint", json!({"at": [150, 150]}))["index"].as_u64().unwrap() as usize;
|
||||
let added = points(&s, id).points[i].color;
|
||||
assert!(cyan_and_black(added), "the added point: {added:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_and_warps_move_the_points() {
|
||||
let (mut s, id) = freeform_rect();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::tests_menucmds::place_undecodable_png;
|
||||
use super::*;
|
||||
use crate::tests_links::Folder;
|
||||
|
||||
@@ -191,6 +192,58 @@ fn art_under_transparent_art_stays_in_its_own_layer() {
|
||||
assert!(d.layers.iter().all(|l| l.children().is_some_and(|c| c.len() == 1)), "{:?}", d.layers);
|
||||
}
|
||||
|
||||
/// #2687's file: a circle filled with a radial gradient from white to #1b2a6b, and a square filled
|
||||
/// with a linear one.
|
||||
const RADIAL_AND_LINEAR: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 400 200">
|
||||
<defs>
|
||||
<radialGradient id="r" cx="0.5" cy="0.5" r="0.5"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#1b2a6b"/></radialGradient>
|
||||
<linearGradient id="l" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#1b2a6b"/></linearGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="100" r="80" fill="url(#r)"/>
|
||||
<rect x="220" y="20" width="160" height="160" fill="url(#l)"/>
|
||||
</svg>"##;
|
||||
|
||||
/// A radial gradient is written with its highlight, `0 0 0 0 Bh` at the center (§3.5.5, §3.5.6).
|
||||
/// #2687's file had none, and the reference app drew the circle in its outer color. The linear one
|
||||
/// has none. Both open again with their type, stops and place.
|
||||
#[test]
|
||||
fn a_radial_gradient_is_written_with_its_highlight() {
|
||||
let mut s = Session::new();
|
||||
let svg = vectorcraft_format::base64_encode(RADIAL_AND_LINEAR.as_bytes());
|
||||
s.execute("document.open", &json!({"name": "t_radial.svg", "dataBase64": svg})).unwrap();
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
let text = String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(text.contains("\n%AI5_BeginGradient: (Gradient)\n(Gradient) 1 2 Bd\n[\n"), "{text}");
|
||||
assert!(text.contains("\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 100 0 80 1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
|
||||
assert!(text.contains("\nBb\n1 (Gradient 2) 220 180 0 160 1 0 0 1 0 0 Bg\nf\n0 BB\n"), "{text}");
|
||||
assert_eq!(text.matches(" Bh\n").count(), 1, "{text}");
|
||||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut found = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_color::Paint::Gradient(g) = n.appearance.fill_paint() {
|
||||
found.push(*g);
|
||||
}
|
||||
});
|
||||
let [radial, linear] = found.as_slice() else { panic!("{found:#?}") };
|
||||
let navy = vectorcraft_color::Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0);
|
||||
use vectorcraft_color::GradientKind::{Linear, Radial};
|
||||
for (g, kind, start, end) in [(radial, Radial, (100.0, 100.0), (180.0, 100.0)), (linear, Linear, (220.0, 20.0), (380.0, 20.0))] {
|
||||
assert_eq!(g.gradient.kind, kind);
|
||||
let [a, b] = g.gradient.stops.as_slice() else { panic!("{g:?}") };
|
||||
assert_eq!((a.offset, b.offset, a.color), (0.0, 1.0, vectorcraft_color::Color::rgb(1.0, 1.0, 1.0)), "{g:?}");
|
||||
assert!(b.color.to_rgb().iter().zip(navy.to_rgb()).all(|(x, y)| (x - y).abs() < 1e-3), "{g:?}");
|
||||
let geom = g.geom.unwrap();
|
||||
assert!((geom.start.x - start.0).abs() < 1e-3 && (geom.start.y - start.1).abs() < 1e-3, "{geom:?}");
|
||||
assert!((geom.end.x - end.0).abs() < 1e-3 && (geom.end.y - end.1).abs() < 1e-3 && geom.focal.is_none(), "{geom:?}");
|
||||
}
|
||||
// White at the center, #1b2a6b at the edge.
|
||||
for ((x, y), want) in [((100.0, 100.0), [255, 255, 255]), ((100.0, 21.0), [27, 42, 107])] {
|
||||
let got = colour_at(d, x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 8), "{got:?} at {x}, {y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w`×`h` PNG of one colour (`rgba`), 72 ppi.
|
||||
fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
@@ -262,6 +315,47 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
|
||||
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
|
||||
}
|
||||
|
||||
/// How many of reply `r`'s warnings say that an image couldn't be decoded, as raster exports do.
|
||||
fn undecoded_notes(r: &Value) -> usize {
|
||||
warnings(r).iter().filter(|w| *w == "an image couldn't be decoded and is missing from the picture").count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flattened_image_that_cant_be_decoded_is_noted_once() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "B"})).unwrap();
|
||||
let cut = place_undecodable_png(&mut s);
|
||||
s.execute("transparency.set", &json!({"ids": [cut.0], "opacity": 50})).unwrap();
|
||||
// Both artboards flatten it.
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}");
|
||||
assert_eq!(undecoded_notes(&r), 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_that_cant_be_decoded_with_an_expanded_effect_is_noted() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let cut = place_undecodable_png(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [cut.0]})).unwrap();
|
||||
// Left out rather than flattened: only expanding the blur draws it.
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap();
|
||||
assert_eq!(undecoded_notes(&r), 1, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_that_decode_give_no_decode_note() {
|
||||
let mut s = shadow_over_square();
|
||||
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}})).unwrap(); // the blue image
|
||||
for mode in ["flatten", "omit"] {
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": mode})).unwrap();
|
||||
assert!(warnings(&r).iter().any(|w| w.contains("transparent images")), "{r}");
|
||||
assert_eq!(undecoded_notes(&r), 0, "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
|
||||
let mut s = shadow_over_square();
|
||||
@@ -277,6 +371,59 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
|
||||
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
|
||||
}
|
||||
|
||||
/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with
|
||||
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
|
||||
/// an orange square on the next layer.
|
||||
const CLIPPED_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
|
||||
<defs><clipPath id="c1"><circle cx="100" cy="100" r="70"/></clipPath></defs>
|
||||
<g id="clipped-image">
|
||||
<circle cx="100" cy="100" r="78" fill="none" stroke="#777777" stroke-width="4"/>
|
||||
<g clip-path="url(#c1)"><image x="20" y="20" width="160" height="160" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGklEQVR42mPMy8tjIAWwcHBwjGoY1TBMNQAAdtYC8NwTJdoAAAAASUVORK5CYII="/></g>
|
||||
</g>
|
||||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||||
</svg>"##;
|
||||
const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
|
||||
<g id="alpha-image">
|
||||
<image x="20" y="20" width="160" height="160" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAeElEQVR42mNgoBAwoguUlZX9x6ehq6uLEacBdXV1/4mxtampiRHDgLa2tv+kOL2qqooRbkBfX99/cvxfVFTEyMLAwMDAwcFBdiCOGjAYDIAnpHPnzpGUFoyMjBgxkvK1a9eIMkRLS4sRZ2a6d+8eXkOUlJQYGagJAOoJHMzSF7FXAAAAAElFTkSuQmCC"/>
|
||||
</g>
|
||||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||||
</svg>"##;
|
||||
|
||||
/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is
|
||||
/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside
|
||||
/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next
|
||||
/// layer follows with the square's exact lines, and the file opens again with both layers, the
|
||||
/// clipping group, the image and the square.
|
||||
#[test]
|
||||
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
|
||||
for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] {
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let xi: Vec<usize> = (0..lines.len()).filter(|i| lines[*i].ends_with(" XI")).collect();
|
||||
assert_eq!(xi.len(), 1, "{text}");
|
||||
let at = xi[0];
|
||||
assert_eq!(lines[at - 2..at], ["%AI5_File:", "%AI5_BeginRaster"], "{layer}");
|
||||
assert!(lines[at].starts_with("[ ") && lines[at].contains(" ] 0 0 "), "{}", lines[at]);
|
||||
let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap();
|
||||
let (last, full) = lines[at + 1..end].split_last().unwrap();
|
||||
assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}");
|
||||
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
|
||||
let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\
|
||||
220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n";
|
||||
assert!(text.contains(square), "{text}");
|
||||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(layer_names(d), [layer, "above"]);
|
||||
assert_eq!(images(d), (1, 1), "{layer}");
|
||||
assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}");
|
||||
assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_saves_those_artboards_and_save_writes_them_again() {
|
||||
let dir = Folder::new("ai7-range");
|
||||
|
||||
@@ -73,6 +73,13 @@ fn all_anchors(nodes: &[Node]) -> Vec<Point> {
|
||||
v
|
||||
}
|
||||
|
||||
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
|
||||
fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
|
||||
let r = s.execute(cmd, p);
|
||||
let want = format!("`{key}` must be a number, not {}", p[key]);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
|
||||
}
|
||||
|
||||
// ---------- Blend ----------
|
||||
|
||||
#[test]
|
||||
@@ -116,6 +123,54 @@ fn blend_steps_that_are_not_a_whole_number_are_refused() {
|
||||
assert_eq!(steps_of(&s, g).len(), 6);
|
||||
}
|
||||
|
||||
/// Make refuses Specified Steps or Distance given as text or as another value that is not a number,
|
||||
/// and makes no blend; `steps: 8` as a number makes 8 steps.
|
||||
#[test]
|
||||
fn blend_make_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0));
|
||||
sel(&mut s, &[a, b]);
|
||||
let n = undo_len(&s);
|
||||
for (key, p) in [
|
||||
("steps", json!({"steps": "8"})),
|
||||
("distance", json!({"distance": "10"})),
|
||||
("value", json!({"spacing": "steps", "value": "3"})),
|
||||
("value", json!({"spacing": "distance", "value": [5]})),
|
||||
("distance", json!({"spacing": "distance", "distance": true})),
|
||||
] {
|
||||
refuses_non_number(&mut s, "object.blend.make", &p, key);
|
||||
}
|
||||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not blended");
|
||||
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 8})).unwrap());
|
||||
assert_eq!(steps_of(&s, g).len(), 10);
|
||||
}
|
||||
|
||||
/// Blend Options refuses Specified Steps or Distance given as text, for the selected blends and for
|
||||
/// the options new blends start with, and changes neither.
|
||||
#[test]
|
||||
fn blend_options_refuse_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let (_, _, g) = blend(&mut s, json!({"steps": 3}));
|
||||
let n = undo_len(&s);
|
||||
let calls = [
|
||||
("steps", json!({"steps": "4"})),
|
||||
("value", json!({"spacing": "steps", "value": "4"})),
|
||||
("distance", json!({"distance": "20"})),
|
||||
("distance", json!({"spacing": "distance", "value": null, "distance": "20"})),
|
||||
];
|
||||
for (key, p) in &calls {
|
||||
refuses_non_number(&mut s, "object.blend.options", p, key);
|
||||
}
|
||||
assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "the blend kept its options");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let defaults = s.prefs.blend_options;
|
||||
for (key, p) in &calls {
|
||||
refuses_non_number(&mut s, "object.blend.options", p, key);
|
||||
}
|
||||
assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_mid_step_interpolates_geometry_and_colour() {
|
||||
let mut s = session();
|
||||
@@ -479,6 +534,95 @@ fn envelope_options_and_edit_contents() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![e]);
|
||||
}
|
||||
|
||||
/// The warp's `bend`, and `h` and `v` by those keys or by their long names, given as text.
|
||||
fn warp_numbers_as_text() -> [(&'static str, Value); 5] {
|
||||
[
|
||||
("bend", json!({"bend": "40"})),
|
||||
("h", json!({"h": "10"})),
|
||||
("v", json!({"v": "-10"})),
|
||||
("horizontalDistortion", json!({"horizontalDistortion": "10"})),
|
||||
("verticalDistortion", json!({"verticalDistortion": "10%"})),
|
||||
]
|
||||
}
|
||||
|
||||
/// Make with Warp refuses a bend or distortion given as text, and makes no envelope.
|
||||
#[test]
|
||||
fn envelope_make_with_warp_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = text_rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let n = undo_len(&s);
|
||||
for (key, p) in warp_numbers_as_text() {
|
||||
refuses_non_number(&mut s, "object.envelope.makeWithWarp", &p, key);
|
||||
}
|
||||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
|
||||
}
|
||||
|
||||
/// Reset with Warp refuses a bend or distortion given as text, and the envelope keeps its warp.
|
||||
#[test]
|
||||
fn envelope_reset_with_warp_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = text_rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
|
||||
let (before, n) = (node(&s, e), undo_len(&s));
|
||||
for (key, p) in warp_numbers_as_text() {
|
||||
refuses_non_number(&mut s, "object.envelope.resetWithWarp", &p, key);
|
||||
}
|
||||
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
|
||||
}
|
||||
|
||||
/// Make with Mesh refuses rows or columns given as text, and makes no envelope.
|
||||
#[test]
|
||||
fn envelope_make_with_mesh_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = text_rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let n = undo_len(&s);
|
||||
for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"rows": 2, "columns": "3"}))] {
|
||||
refuses_non_number(&mut s, "object.envelope.makeWithMesh", &p, key);
|
||||
}
|
||||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped");
|
||||
}
|
||||
|
||||
/// Reset with Mesh refuses rows or columns given as text, and the envelope keeps its mesh.
|
||||
#[test]
|
||||
fn envelope_reset_with_mesh_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = text_rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap());
|
||||
let (before, n) = (node(&s, e), undo_len(&s));
|
||||
for (key, p) in [("rows", json!({"rows": "3"})), ("cols", json!({"cols": "3"})), ("columns", json!({"columns": "3", "maintainShape": false}))] {
|
||||
refuses_non_number(&mut s, "object.envelope.resetWithMesh", &p, key);
|
||||
}
|
||||
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
|
||||
}
|
||||
|
||||
/// Envelope Options refuses a fidelity, bend or distortion given as text, for the selected
|
||||
/// envelope and for the options new envelopes get, and changes neither.
|
||||
#[test]
|
||||
fn envelope_options_refuse_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = text_rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap());
|
||||
let (before, n) = (node(&s, e), undo_len(&s));
|
||||
let mut calls = vec![("fidelity", json!({"fidelity": "80", "antiAlias": false}))];
|
||||
calls.extend(warp_numbers_as_text());
|
||||
for (key, p) in &calls {
|
||||
refuses_non_number(&mut s, "object.envelope.options", p, key);
|
||||
}
|
||||
assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
for (key, p) in &calls {
|
||||
refuses_non_number(&mut s, "object.envelope.options", p, key);
|
||||
}
|
||||
assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_renders_distorted() {
|
||||
let mut s = session();
|
||||
@@ -634,6 +778,46 @@ fn mesh_commands_refuse_an_index_or_count_that_is_not_a_whole_number() {
|
||||
assert_eq!(mesh_of(&s, a).points[4].p, Point::new(40.0, 60.0));
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh refuses rows, columns or a highlight given as text, and an `at` that is not
|
||||
/// two numbers, and makes no mesh; `at: [50, 40]` with a null highlight makes one.
|
||||
#[test]
|
||||
fn mesh_create_refuses_a_number_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let n = undo_len(&s);
|
||||
for (key, p) in [
|
||||
("rows", json!({"rows": "3"})),
|
||||
("cols", json!({"cols": "2"})),
|
||||
("columns", json!({"columns": "2"})),
|
||||
("highlight", json!({"appearance": "center", "highlight": "50"})),
|
||||
] {
|
||||
refuses_non_number(&mut s, "object.mesh.create", &p, key);
|
||||
}
|
||||
for at in [json!(["50", "40"]), json!([50]), json!(50), json!({"x": 50, "y": 40})] {
|
||||
let r = s.execute("object.mesh.create", &json!({ "at": at }));
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`at` must be [x, y]"), "{at}: {r:?}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "still a path");
|
||||
s.execute("object.mesh.create", &json!({"at": [50, 40], "highlight": null})).unwrap();
|
||||
assert_eq!((mesh_of(&s, a).rows, mesh_of(&s, a).cols), (2, 2));
|
||||
}
|
||||
|
||||
/// Set Mesh Point Color refuses an opacity given as text, and the point keeps its color and opacity.
|
||||
#[test]
|
||||
fn mesh_set_point_color_refuses_an_opacity_given_as_text() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
let (before, n) = (mesh_of(&s, a), undo_len(&s));
|
||||
for p in [json!({"id": a.0, "index": 3, "color": "#00ff00", "opacity": "0.5"}), json!({"id": a.0, "index": 3, "opacity": "50%"})] {
|
||||
refuses_non_number(&mut s, "object.mesh.setPointColor", &p, "opacity");
|
||||
}
|
||||
assert_eq!((mesh_of(&s, a), undo_len(&s)), (before, n), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_create_at_point_single_undo() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -37,6 +37,27 @@ fn gradient_rect(s: &mut Session, r: Rect, gradient: Value, stroke: bool) -> Nod
|
||||
NodeId(id)
|
||||
}
|
||||
|
||||
/// A selected square `r` with a freeform fill whose four automatic points take `colors` in turn.
|
||||
fn freeform_rect(s: &mut Session, r: Rect, colors: [Value; 4]) -> NodeId {
|
||||
let id = gradient_rect(s, r, json!({"kind": "freeform"}), false);
|
||||
for (i, c) in colors.into_iter().enumerate() {
|
||||
run(s, "paint.freeform.setPoint", json!({"index": i, "color": c, "stroke": false}));
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// A CMYK color's inks.
|
||||
fn inks(c: Color) -> [f32; 4] {
|
||||
match c {
|
||||
Color::Cmyk { c, m, y, k } => [c, m, y, k],
|
||||
c => panic!("a CMYK color expected: {c:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_inks(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
|
||||
}
|
||||
|
||||
/// The one selected object after a command.
|
||||
fn selected(s: &Session) -> Node {
|
||||
let sel = &s.doc().unwrap().selection.objects;
|
||||
@@ -792,6 +813,18 @@ fn create_gradient_mesh_takes_its_colours_from_the_gradient() {
|
||||
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_gradient_mesh_keeps_the_inks_of_freeform_points() {
|
||||
let mut s = session();
|
||||
let one = json!({"c": 0.5, "m": 0.2, "y": 0.0, "k": 0.1});
|
||||
let id = freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [one.clone(), one.clone(), one.clone(), one]);
|
||||
run(&mut s, "object.mesh.create", json!({"rows": 2, "cols": 2}));
|
||||
let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() };
|
||||
for p in &m.points {
|
||||
assert!(close_inks(inks(p.color), [0.5, 0.2, 0.0, 0.1]), "{:?}", p.color);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_colours_keep_the_stops_colour_model() {
|
||||
let mut s = session();
|
||||
@@ -804,6 +837,44 @@ fn expanded_colours_keep_the_stops_colour_model() {
|
||||
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
|
||||
let ink = |c: f32, m: f32, y: f32, k: f32| json!({"c": c, "m": m, "y": y, "k": k});
|
||||
// The mesh's colors, after checking that it draws within `most` (mean channel difference) of
|
||||
// the gradient.
|
||||
let expanded = |s: &mut Session, most: f64| {
|
||||
let before = render(s);
|
||||
run(s, "object.expand", json!({}));
|
||||
let diff = mean_diff(&before, &render(s));
|
||||
assert!(diff < most, "looks the same: {diff}");
|
||||
let NodeKind::Mesh(m) = selected(s).kind else { panic!("a mesh: {:?}", selected(s).kind) };
|
||||
m.points.into_iter().map(|p| p.color).collect::<Vec<_>>()
|
||||
};
|
||||
// Points of one ink set give every mesh point that ink set.
|
||||
let mut s = session();
|
||||
let one = ink(0.5, 0.2, 0.0, 0.1);
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [one.clone(), one.clone(), one.clone(), one]);
|
||||
for c in expanded(&mut s, 1.0) {
|
||||
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
|
||||
}
|
||||
// Cyan and black points mix as inks: cyan and black only, adding up to one.
|
||||
let mut s = session();
|
||||
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
|
||||
for c in expanded(&mut s, 5.0) {
|
||||
let [c, m, y, k] = inks(c);
|
||||
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
|
||||
}
|
||||
// Points in different models mix in display RGB, stored in a CMYK document as CMYK.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk"})).unwrap();
|
||||
let gray = json!({"gray": 0.5});
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan, gray.clone(), gray]);
|
||||
for c in expanded(&mut s, 2.5) {
|
||||
inks(c);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() {
|
||||
// Fill unchecked: the stroke is outlined, the gradient fill stays a gradient.
|
||||
|
||||
@@ -6,6 +6,7 @@ use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::tests_menucmds::place_undecodable_png;
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
@@ -178,6 +179,50 @@ fn options_rename_updates_swatch_and_paints() {
|
||||
assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Spots"));
|
||||
}
|
||||
|
||||
/// Pattern Options refuses a new name that a swatch of another kind, a second swatch of the
|
||||
/// pattern or a color group has, and takes a name that only the pattern's own swatch has.
|
||||
#[test]
|
||||
fn options_rename_refuses_a_name_another_swatch_has() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
s.execute("swatch.new", &json!({"name": "Brick", "color": "#aa3300"})).unwrap();
|
||||
s.execute("swatch.newGroup", &json!({"name": "Mine", "colors": ["#0000ff"]})).unwrap();
|
||||
// "Dots copy": a second swatch of the pattern.
|
||||
s.execute("swatch.duplicate", &json!({"name": "Dots"})).unwrap();
|
||||
for taken in [" Brick ", "Mine", "Dots copy", "[Registration]"] {
|
||||
let e = s.execute("pattern.options", &json!({"name": "Dots", "newName": taken})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("a swatch or color group named `{}` exists", taken.trim())), "{e}");
|
||||
}
|
||||
assert!(doc(&s).pattern("Dots").is_some());
|
||||
let named = |s: &Session, n: &str| doc(s).swatches_iter().filter(|w| w.name == n).count();
|
||||
assert_eq!((named(&s, "Brick"), named(&s, "Dots"), named(&s, "Dots copy")), (1, 1, 1));
|
||||
// The pattern's own swatch, renamed in Swatch Options: the pattern can take its name.
|
||||
s.execute("swatch.delete", &json!({"name": "Dots copy"})).unwrap();
|
||||
s.execute("swatch.edit", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
|
||||
assert_eq!(s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap()["name"], "Spots");
|
||||
assert_eq!(named(&s, "Spots"), 1);
|
||||
assert!(matches!(&doc(&s).swatch("Spots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Spots"));
|
||||
// With no swatch of the pattern under its name, a color group's name is still refused.
|
||||
s.execute("swatch.edit", &json!({"name": "Spots", "newName": "Spot Tiles"})).unwrap();
|
||||
assert!(s.execute("pattern.options", &json!({"name": "Spots", "newName": "Mine"})).is_err());
|
||||
}
|
||||
|
||||
/// Renaming a pattern renames only its own swatches: a color swatch that has the pattern's old
|
||||
/// name keeps it, and art linked to that swatch stays linked.
|
||||
#[test]
|
||||
fn options_rename_leaves_other_swatches_of_the_old_name() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
s.execute("swatch.edit", &json!({"name": "Dots", "newName": "Dot Tiles"})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Dots", "color": "#3366cc", "global": true})).unwrap();
|
||||
let r = rect(&mut s, 100.0, 100.0, 20.0, 20.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
|
||||
assert!(doc(&s).swatch("Dots").is_some_and(|w| matches!(w.paint, Paint::Solid { .. })), "the color swatch keeps its name");
|
||||
assert!(matches!(&doc(&s).swatch("Dot Tiles").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Spots"));
|
||||
assert!(matches!(node(&s, r).appearance.fill_paint(), Paint::Solid { swatch: Some(l), .. } if l == "Dots"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_pattern_unpaints_objects() {
|
||||
let mut s = session();
|
||||
@@ -302,6 +347,20 @@ fn made_copied_and_renamed_patterns_trim_their_names() {
|
||||
assert!(doc(&s).swatch("Spots").is_some() && doc(&s).swatch(" Spots ").is_none());
|
||||
}
|
||||
|
||||
/// Make and Save a Copy number a name that a color group has, as they number a swatch's name
|
||||
/// (swatches and color groups share one namespace).
|
||||
#[test]
|
||||
fn made_and_copied_patterns_number_a_color_groups_name() {
|
||||
let mut s = session();
|
||||
s.execute("swatch.newGroup", &json!({"name": "Mine", "colors": ["#0000ff"]})).unwrap();
|
||||
s.execute("swatch.newGroup", &json!({"name": "New Pattern 1", "colors": ["#00ff00"]})).unwrap();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
assert_eq!(s.execute("object.pattern.make", &json!({"ids": [r.0], "name": "Mine", "edit": false})).unwrap()["name"], "Mine 2");
|
||||
assert_eq!(s.execute("object.pattern.make", &json!({"ids": [r.0], "edit": false})).unwrap()["name"], "New Pattern 2");
|
||||
s.execute("object.pattern.edit", &json!({"name": "Mine 2"})).unwrap();
|
||||
assert_eq!(s.execute("object.pattern.saveCopy", &json!({"name": "Mine"})).unwrap()["name"], "Mine 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_make_pattern() {
|
||||
let mut s = session();
|
||||
@@ -400,6 +459,37 @@ fn every_raster_export_says_when_a_pattern_holds_an_image_that_could_not_be_deco
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raster_export_says_when_a_pattern_tile_draws_an_undecodable_image_from_its_kept_rasterize_image() {
|
||||
// A PNG whose pixel data is cut short, with Rasterize, a drop shadow and a blur: the shadow
|
||||
// draws the Rasterize image, and the blurred content draws the image kept from that draw.
|
||||
let mut s = session();
|
||||
let placed = place_undecodable_png(&mut s);
|
||||
for (effect, params) in [
|
||||
("rasterize", json!({"background": "white"})),
|
||||
("stylize.dropShadow", json!({"x": 2, "y": 2, "blur": 1})),
|
||||
("blur.gaussian", json!({"radius": 4})),
|
||||
] {
|
||||
s.execute("effect.apply", &json!({"effect": effect, "params": params, "ids": [placed.0]})).unwrap();
|
||||
}
|
||||
sel(&mut s, &[placed]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Cut", "width": 80, "height": 40})).unwrap();
|
||||
s.execute("object.pattern.done", &json!({})).unwrap();
|
||||
sel(&mut s, &[placed]);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Cut"})).unwrap();
|
||||
let warns = |s: &mut Session| {
|
||||
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||||
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"))))
|
||||
};
|
||||
assert!(warns(&mut s), "the first export");
|
||||
// A new overlap draws a new tile of the same art, and the tile renderer draws its shadow and
|
||||
// its blurred content from the rasters it kept.
|
||||
s.execute("pattern.options", &json!({"name": "Cut", "overlap": {"h": "right"}})).unwrap();
|
||||
assert!(warns(&mut s), "the export after Pattern Options");
|
||||
}
|
||||
|
||||
// ---------- repeat ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -57,6 +57,48 @@ fn serialize(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn eps_of(s: &mut Session) -> Vec<u8> {
|
||||
let v = s.execute("document.exportEps", &json!({})).unwrap();
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `file.place.info`'s width and height for `bytes` named `name`, with `extra` params.
|
||||
fn info_size(s: &mut Session, name: &str, bytes: &[u8], extra: Value) -> (f64, f64) {
|
||||
let mut p = file(name, bytes);
|
||||
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
let r = s.execute("file.place.info", &p).unwrap();
|
||||
(r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap())
|
||||
}
|
||||
|
||||
/// The bounds of what the active document's art draws ([`vectorcraft_render::encode::art_bounds`])
|
||||
/// lie within a millionth of a point of `want`.
|
||||
fn drawn_at(s: &Session, want: Rect) -> bool {
|
||||
let b = vectorcraft_render::encode::art_bounds(&s.doc().unwrap().doc).unwrap_or_default();
|
||||
[b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// A 400 × 300 pt document holding one group: a 40 × 40 pt square at (20, 20) and, hidden, a
|
||||
/// 170 × 42 pt rectangle at (100, 100). Returns it with the square's visual bounds.
|
||||
fn group_with_a_hidden_member() -> (Session, Rect) {
|
||||
let mut s = session();
|
||||
let square = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let wide = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 170, "height": 42})).unwrap()["id"].clone();
|
||||
s.execute("object.group", &json!({"ids": [square, wide]})).unwrap();
|
||||
s.execute("object.hide", &json!({"ids": [wide]})).unwrap();
|
||||
assert!(!node(&s, &wide).visible);
|
||||
let shown = node(&s, &square).visual_bounds().unwrap();
|
||||
(s, shown)
|
||||
}
|
||||
|
||||
/// How many of `n` and the objects in it are hidden.
|
||||
fn hidden_in(n: &Node) -> usize {
|
||||
let mut hidden = 0;
|
||||
n.walk(&mut |c| hidden += usize::from(!c.visible));
|
||||
hidden
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() {
|
||||
let mut s = session();
|
||||
@@ -218,6 +260,120 @@ fn pdf_pages_and_native_artboards_place_as_clipped_groups() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With crop "bounding", the art's box is the box of what shows, as an export measures it: a hidden
|
||||
/// member of a group does not count, and is still placed, hidden.
|
||||
#[test]
|
||||
fn bounding_box_placement_measures_only_the_art_that_shows() {
|
||||
let (mut src, shown) = group_with_a_hidden_member();
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300"><g><rect x="20" y="20" width="40" height="40"/><rect x="100" y="100" width="170" height="42" style="display:none"/></g></svg>"#;
|
||||
// The sample's group `Pair` has a hidden member 42 pt tall; what shows is 170 × 40 pt.
|
||||
let sample = vectorcraft_testkit::ai::eps(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_ps());
|
||||
let cases: [(&str, &[u8], (f64, f64)); 4] = [
|
||||
("pair.eps", &eps, (shown.width(), shown.height())),
|
||||
("pair.vectorcraft", &native, (shown.width(), shown.height())),
|
||||
("pair.svg", svg, (40.0, 40.0)),
|
||||
("art.eps", &sample, (170.0, 40.0)),
|
||||
];
|
||||
for (name, bytes, (w, h)) in cases {
|
||||
let mut s = session();
|
||||
let (iw, ih) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
assert!(close(iw, w) && close(ih, h), "{name}: {iw} × {ih}, not {w} × {h}");
|
||||
// `rect` fits what shows, centered.
|
||||
let r = place(&mut s, name, bytes, json!({"crop": "bounding", "rect": [100, 100, 80, 80]}));
|
||||
let k = (80.0 / w).min(80.0 / h);
|
||||
let want = Rect::from_center_size((140.0, 140.0), (w * k, h * k));
|
||||
assert!(drawn_at(&s, want), "{name}: not {want:?}");
|
||||
assert_eq!(hidden_in(&node(&s, &r["ids"][0])), 1, "{name}: the hidden member is placed, hidden");
|
||||
}
|
||||
}
|
||||
|
||||
/// With crop "bounding", an effect's reach counts in the art's box, as in an export of the art.
|
||||
#[test]
|
||||
fn bounding_box_placement_counts_a_drop_shadow() {
|
||||
let mut src = session();
|
||||
let id = src.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let square = node(&src, &id).visual_bounds().unwrap();
|
||||
src.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 8, "y": 0, "blur": 0}})).unwrap();
|
||||
let export = vectorcraft_render::encode::art_bounds(&src.doc().unwrap().doc).unwrap();
|
||||
assert!(export.width() > square.width() + 7.0, "{export:?}");
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let cases: [(&str, &[u8]); 2] = [("shadow.eps", &eps), ("shadow.vectorcraft", &native)];
|
||||
for (name, bytes) in cases {
|
||||
let mut s = session();
|
||||
let (w, h) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
assert!(close(w, export.width()) && close(h, export.height()), "{name}: {w} × {h}, not {export:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol instance in the placed art is measured by its symbol's art, for the art's box and for
|
||||
/// the size `file.place` answers.
|
||||
#[test]
|
||||
fn a_placed_symbol_instance_is_measured_by_its_art() {
|
||||
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300"><defs><symbol id="s"><rect width="100" height="10"/></symbol></defs><use xlink:href="#s" x="50" y="50"/></svg>"##;
|
||||
let mut s = session();
|
||||
assert_eq!(info_size(&mut s, "bar.svg", svg, json!({"crop": "bounding"})), (100.0, 10.0));
|
||||
let r = place(&mut s, "bar.svg", svg, json!({"crop": "bounding", "rect": [100, 100, 200, 200]}));
|
||||
assert!(close(r["width"].as_f64().unwrap(), 200.0) && close(r["height"].as_f64().unwrap(), 20.0), "{r}");
|
||||
assert!(drawn_at(&s, Rect::new(100.0, 190.0, 300.0, 210.0)));
|
||||
// Replace centers an image on it and gives a text file its size.
|
||||
let r = place(&mut s, "dot.png", &png(10, 10, [0, 0, 0, 255], None), json!({"replace": true}));
|
||||
let c = node(&s, &r["ids"][0]).geometric_bounds().unwrap().center();
|
||||
assert!(close(c.x, 200.0) && close(c.y, 200.0), "{c:?}");
|
||||
let r = place(&mut s, "bar.svg", svg, json!({"crop": "bounding", "rect": [100, 100, 200, 200]}));
|
||||
assert!(close(r["width"].as_f64().unwrap(), 200.0), "{r}");
|
||||
let r = place(&mut s, "note.txt", b"Hello", json!({"replace": true}));
|
||||
let NodeKind::Text(t) = &node(&s, &r["ids"][0]).kind else { panic!("area type") };
|
||||
let frame = t.area_frame().and_then(|f| f.bounds()).unwrap();
|
||||
assert!(close(frame.width(), 200.0) && close(frame.height(), 20.0), "{frame:?}");
|
||||
}
|
||||
|
||||
/// Crop "crop" still frames the art by its artboard (an SVG by its viewBox), hidden members or not.
|
||||
#[test]
|
||||
fn crop_placement_keeps_the_artboard_with_a_hidden_member() {
|
||||
let (mut src, _) = group_with_a_hidden_member();
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"><g><rect x="20" y="20" width="40" height="40"/><rect x="100" y="100" width="170" height="42" style="display:none"/></g></svg>"#;
|
||||
let cases: [(&str, &[u8]); 3] = [("pair.eps", &eps), ("pair.vectorcraft", &native), ("pair.svg", svg)];
|
||||
for (name, bytes) in cases {
|
||||
let mut s = session();
|
||||
assert_eq!(info_size(&mut s, name, bytes, json!({"crop": "crop"})), (400.0, 300.0), "{name}");
|
||||
let r = place(&mut s, name, bytes, json!({"crop": "crop"}));
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(400.0), Some(300.0)), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's or artboard's art is the objects whose visible art lies on it: a group whose only part
|
||||
/// on the artboard is hidden stays out, and a hidden object on the artboard is still placed, hidden.
|
||||
#[test]
|
||||
fn a_group_whose_only_part_on_the_artboard_is_hidden_is_left_out() {
|
||||
let mut src = session();
|
||||
let off = src.execute("shape.rectangle", &json!({"x": -300, "y": 20, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||||
let under = src.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 170, "height": 42})).unwrap()["id"].clone();
|
||||
src.execute("object.group", &json!({"ids": [off, under]})).unwrap();
|
||||
src.execute("object.hide", &json!({"ids": [under]})).unwrap();
|
||||
let dot = src.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
let shown = node(&src, &dot).visual_bounds().unwrap();
|
||||
let ghost = src.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
src.execute("object.hide", &json!({"ids": [ghost]})).unwrap();
|
||||
let eps = eps_of(&mut src);
|
||||
let native = serialize(&mut src, "vectorcraft");
|
||||
let cases: [(&str, &[u8]); 2] = [("parts.eps", &eps), ("parts.vectorcraft", &native)];
|
||||
for (name, bytes) in cases {
|
||||
let mut s = session();
|
||||
let (w, h) = info_size(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
assert!(close(w, shown.width()) && close(h, shown.height()), "{name}: {w} × {h}");
|
||||
let r = place(&mut s, name, bytes, json!({"crop": "bounding"}));
|
||||
let g = node(&s, &r["ids"][0]);
|
||||
let kids = g.children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "{name}: the circle and the hidden square");
|
||||
assert!(kids[0].visible && !kids[1].visible, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() {
|
||||
// The file: a global "Brand" red rectangle and a "Star" symbol instance.
|
||||
|
||||
@@ -175,6 +175,10 @@ fn redefine_updates_every_linked_object() {
|
||||
let blue = Paint::solid(Color::from_hex("#0000ff").unwrap());
|
||||
assert_eq!((fill(&s, b), fill(&s, c)), (blue.clone(), blue));
|
||||
assert_eq!(linked(&mut s, "S"), vec![a.0, b.0, c.0]);
|
||||
// The step is named from a template with the style's name in it.
|
||||
let step = s.doc().unwrap().history.undo.last().unwrap().clone();
|
||||
let named = step.named().map(|n| (n.template, n.name.as_str(), n.built_in));
|
||||
assert_eq!((step.label.as_str(), named), ("Redefine Graphic Style “S”", Some(("Redefine Graphic Style “{name}”", "S", false))));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(fill(&s, b), Paint::solid(Color::WHITE));
|
||||
}
|
||||
|
||||
@@ -228,6 +228,45 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() {
|
||||
assert_eq!(serde_json::from_str::<vectorcraft_doc::Document>(&json).unwrap(), *doc(&s));
|
||||
}
|
||||
|
||||
/// The other swatch commands store RGB colors in a CMYK document as `swatch.new {color}` does:
|
||||
/// `swatch.new {colors}`, `swatch.newGroup {colors}`, `swatch.editGroup {colors}` and
|
||||
/// `swatch.edit {color}`. A color a swatch already has, and a color given with `mode`, keep their
|
||||
/// model.
|
||||
#[test]
|
||||
fn a_cmyk_document_stores_rgb_colors_given_to_the_swatch_commands_as_cmyk() {
|
||||
use vectorcraft_color::Color;
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent);
|
||||
let (red, green) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0));
|
||||
let color = |s: &Session, n: &Value| doc(s).swatch(n.as_str().unwrap()).and_then(|w| w.paint.color());
|
||||
let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", {"c": 0, "m": 0, "y": 100, "k": 0}]}));
|
||||
assert_eq!((color(&s, &r["names"][0]), color(&s, &r["names"][1])), (Some(managed(red)), Some(Color::cmyk(0.0, 0.0, 1.0, 0.0))));
|
||||
let r = run(&mut s, "swatch.newGroup", json!({"name": "G", "colors": ["#00ff00"]}));
|
||||
assert_eq!(color(&s, &r["swatches"][0]), Some(managed(green)));
|
||||
let kept = run(&mut s, "swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "keepModel": true}));
|
||||
assert_eq!(color(&s, &kept["swatches"][0]), Some(green), "keepModel keeps RGB");
|
||||
let r = run(&mut s, "swatch.editGroup", json!({"group": "G", "colors": ["#ff0000", "#00ff00"]}));
|
||||
assert_eq!((color(&s, &r["swatches"][0]), color(&s, &r["swatches"][1])), (Some(managed(red)), Some(managed(green))));
|
||||
let name = run(&mut s, "swatch.new", json!({"color": "#000000", "keepModel": true}))["name"].clone();
|
||||
assert_eq!(color(&s, &name), Some(Color::BLACK));
|
||||
run(&mut s, "swatch.edit", json!({"name": name, "color": "#ff0000"}));
|
||||
assert_eq!(color(&s, &name), Some(managed(red)));
|
||||
// An RGB swatch sent back unchanged stays RGB.
|
||||
let rgb = run(&mut s, "swatch.new", json!({"color": "#00ff00", "keepModel": true}))["name"].clone();
|
||||
run(&mut s, "swatch.edit", json!({"name": rgb, "color": "#00ff00", "global": true}));
|
||||
assert_eq!(color(&s, &rgb), Some(green));
|
||||
run(&mut s, "swatch.newGroup", json!({"name": "H", "swatches": [rgb]}));
|
||||
let r = run(&mut s, "swatch.editGroup", json!({"group": "H", "colors": ["#00ff00", "#ff0000"]}));
|
||||
assert_eq!((color(&s, &r["swatches"][0]), color(&s, &r["swatches"][1])), (Some(green), Some(managed(red))));
|
||||
// With `mode`, the color is converted once, into that mode.
|
||||
let r = run(&mut s, "swatch.new", json!({"color": "#00ff00", "mode": "rgb"}));
|
||||
assert_eq!(color(&s, &r["name"]), Some(green));
|
||||
let r = run(&mut s, "swatch.new", json!({"colors": ["#00ff00"], "mode": "rgb"}));
|
||||
assert_eq!(color(&s, &r["names"][0]), Some(green));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() {
|
||||
use vectorcraft_color::Color;
|
||||
|
||||
@@ -261,3 +261,51 @@ fn the_eyedropper_takes_an_image_pixel_into_the_working_rgb_space() {
|
||||
let [r, g, b, _] = shown.unwrap_or_default();
|
||||
assert!(r.abs_diff(83) <= 1 && g.abs_diff(170) <= 1 && b.abs_diff(223) <= 1, "{shown:?} for 83/170/223");
|
||||
}
|
||||
|
||||
/// With a working RGB space other than sRGB, a freeform gradient of RGB points expands to mesh
|
||||
/// points with the points' own RGB values, and points in different models expand to mesh points
|
||||
/// that show the colors the gradient shows there.
|
||||
#[test]
|
||||
fn a_freeform_gradient_expands_with_its_rgb_values_in_the_working_rgb_space() {
|
||||
use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
let _g = settings();
|
||||
let old = cms::active_settings();
|
||||
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..old.clone() }).unwrap();
|
||||
// Each mesh point's color, the display color it shows and the display color the gradient
|
||||
// shows at its place.
|
||||
let expand = |colors: [serde_json::Value; 4]| {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 20.0, 20.0, 160.0, 160.0);
|
||||
select(&mut s, &[id]);
|
||||
exec(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}}));
|
||||
exec(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
for (i, c) in colors.into_iter().enumerate() {
|
||||
exec(&mut s, "paint.freeform.setPoint", json!({"index": i, "color": c, "stroke": false}));
|
||||
}
|
||||
let b = Rect::new(20.0, 20.0, 180.0, 180.0);
|
||||
let field = match s.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint()) {
|
||||
Some(Paint::Gradient(g)) => g.freeform_on(b).field(spread_scale(b)),
|
||||
p => panic!("a gradient fill: {p:?}"),
|
||||
};
|
||||
exec(&mut s, "object.expand", json!({}));
|
||||
let d = &s.doc().unwrap();
|
||||
let mesh = d.selection.objects.first().and_then(|id| d.doc.node(*id)).map(|n| n.kind.clone());
|
||||
let Some(NodeKind::Mesh(m)) = mesh else { panic!("a mesh: {mesh:?}") };
|
||||
m.points.iter().map(|p| (p.color, p.color.to_rgb(), field.sample(p.p).0)).collect::<Vec<_>>()
|
||||
};
|
||||
let rgb = expand([json!("#3399e6"), json!("#3399e6"), json!("#3399e6"), json!("#3399e6")]);
|
||||
let mixed = expand([json!("#3399e6"), json!("#3399e6"), json!({"gray": 0.5}), json!({"gray": 0.5})]);
|
||||
cms::set_active(&old).unwrap();
|
||||
let want = Color::rgb8(0x33, 0x99, 0xe6);
|
||||
for (c, ..) in &rgb {
|
||||
let (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) = (*c, want) else { panic!("an RGB point: {c:?}") };
|
||||
assert!((r - r2).abs() < 1e-5 && (g - g2).abs() < 1e-5 && (b - b2).abs() < 1e-5, "{c:?} for {want:?}");
|
||||
}
|
||||
for (c, shown, field) in &mixed {
|
||||
let close = shown.iter().zip(field).all(|(a, b)| (a - b).abs() < 2.0 / 255.0);
|
||||
assert!(matches!(c, Color::Rgb { .. }) && close, "{c:?} shows {shown:?} for {field:?}");
|
||||
}
|
||||
}
|
||||
@@ -823,8 +823,8 @@ fn the_file_info_sample_reaches_the_reader() {
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// File Info read on open: mutated XMP packets and junk information dictionaries, opened as a
|
||||
/// PDF and as a `.ai`.
|
||||
/// File Info read on open: mutated XMP packets, some with elements nested deeply, and junk
|
||||
/// information dictionaries, opened as a PDF and as a `.ai`.
|
||||
#[test]
|
||||
fn pdf_file_info_never_panics(
|
||||
cut in 0usize..2_000,
|
||||
@@ -836,8 +836,11 @@ proptest! {
|
||||
"/Subject <FEFF00> /Author <FEFFD800> /Keywords <EFBBBF> /Title (\\377\\376)",
|
||||
"/CreationDate (D:00000101000000Z) /ModDate (D:99991231235959+23'59')",
|
||||
]),
|
||||
nest in prop::sample::select(vec![0usize, 0, 0, 251, 100_000]),
|
||||
) {
|
||||
let pdf = file_info_pdf(info, &mutate_text(RICH_XMP, cut, &edits));
|
||||
// `nest` elements nested in one another around the packet.
|
||||
let xmp = format!("{}{}{}", "<a>".repeat(nest), mutate_text(RICH_XMP, cut, &edits), "</a>".repeat(nest));
|
||||
let pdf = file_info_pdf(info, &xmp);
|
||||
let b64 = vectorcraft_format::base64_encode(&pdf);
|
||||
let r = catch_quiet(|| {
|
||||
let _ = vectorcraft_pdf::file_info(&pdf, None);
|
||||
@@ -2427,6 +2430,19 @@ proptest! {
|
||||
}
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning
|
||||
/// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't
|
||||
/// handle, and then the layers aren't read).
|
||||
#[test]
|
||||
fn ai_variable_library_never_panics(cut in 0usize..12_000, edits in prop::collection::vec((0usize..12_000, prop::sample::select(vec!['0', '9', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'Z'])), 0..12)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let library = ai::variable_library(&["Name", "Number"], &["One", "Two"]);
|
||||
let data = ai::editing_data(100.0, 100.0, &format!("{library}{}", ai::layer("L", "0 g\n10 10 m\n20 20 L\nS\n")));
|
||||
let text = mutate_text(&data, cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
survive("mutated .ai variable library", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named,
|
||||
|
||||
@@ -96,6 +96,48 @@ fn an_effect_color_it_cannot_draw_is_refused() {
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["color"], json!("not-a-color"));
|
||||
}
|
||||
|
||||
/// A `channel` that Levels or Curves doesn't have (which they drew as RGB) is refused by
|
||||
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` themselves, not only by the check,
|
||||
/// and the document and its undo history stay as they were. The channels they read pass. A
|
||||
/// document saved with another channel still opens with it.
|
||||
#[test]
|
||||
fn a_tone_curve_channel_it_does_not_have_is_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"channel": "red"}})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.active().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len())
|
||||
};
|
||||
let before = state(&s);
|
||||
let why = "`channel` must be one of rgb, red, green, blue";
|
||||
for bad in [json!("alpha"), json!("cyan"), json!(""), json!("reds"), json!(null)] {
|
||||
for (cmd, params) in [
|
||||
("effect.setParams", json!({"index": 0, "params": {"channel": bad}})),
|
||||
("effect.apply", json!({"effect": "adjust.curves", "params": {"channel": bad}})),
|
||||
("appearance.addEffect", json!({"effect": "adjust.levels", "params": {"channel": bad}})),
|
||||
] {
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{cmd} {params}: {e}");
|
||||
let checked = s.check_params(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(checked.contains(why), "{cmd} {params}: {checked}");
|
||||
assert!(state(&s) == before, "{cmd} {params} changed the document or its undo history");
|
||||
}
|
||||
}
|
||||
let stored = |s: &Session| s.active().unwrap().doc.layers[0].children().unwrap()[0].appearance.effects[0].params["channel"].clone();
|
||||
for good in [json!("rgb"), json!("green"), json!("blue"), json!("Red"), json!("RGB"), json!("r"), json!("G"), json!("b")] {
|
||||
let params = json!({"index": 0, "params": {"channel": good}});
|
||||
s.check_params("effect.setParams", ¶ms).unwrap_or_else(|e| panic!("{good}: {e}"));
|
||||
s.execute("effect.setParams", ¶ms).unwrap_or_else(|e| panic!("{good}: {e}"));
|
||||
assert_eq!(stored(&s), good);
|
||||
}
|
||||
let mut doc = (*s.active().unwrap().doc).clone();
|
||||
doc.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["channel"] = json!("alpha");
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["channel"], json!("alpha"));
|
||||
}
|
||||
|
||||
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
|
||||
/// sent back through the control channel or MCP.
|
||||
#[test]
|
||||
|
||||
@@ -31,7 +31,7 @@ use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub(crate) use frames::text_frames;
|
||||
pub use read::{Structure, artboards, read};
|
||||
pub use read::{Structure, artboards, read, variables};
|
||||
pub(crate) use read::{slot_frame, slot_of};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else
|
||||
//! the art size around the template box's centre, else (a file in the legacy format another
|
||||
//! app wrote) the bounding box.
|
||||
//! - Variables: the variable library, XML nodes of the `/Document` dictionary, isn't read; a note
|
||||
//! gives how many variables and data sets it has ([`VariableLibrary`]).
|
||||
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
|
||||
//! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and
|
||||
//! an alpha channel that hides part of such an image becomes its opacity mask; other samples
|
||||
@@ -184,6 +186,102 @@ fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> {
|
||||
r.artboards
|
||||
}
|
||||
|
||||
/// How many variables and data sets the variable library of the editing data has: XML nodes
|
||||
/// (`/XMLNode`) of the `/Document` dictionary, a `variableSets` element holding `variableSet`s, each
|
||||
/// with its `variables` (`variable` elements) and `sampleDataSets` (`sampleDataSet` elements).
|
||||
/// VectorCraft doesn't read them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct VariableLibrary {
|
||||
pub variables: usize,
|
||||
pub data_sets: usize,
|
||||
}
|
||||
|
||||
impl VariableLibrary {
|
||||
/// The import's note that the library wasn't read, when it has a variable or a data set.
|
||||
pub fn note(self) -> Option<String> {
|
||||
let Self { variables, data_sets } = self;
|
||||
let s = |n: usize| if n == 1 { "" } else { "s" };
|
||||
(variables + data_sets > 0).then(|| {
|
||||
format!(
|
||||
"the file's variables and data sets weren't read ({variables} variable{}, {data_sets} data set{}): the Variables panel doesn't list them",
|
||||
s(variables),
|
||||
s(data_sets)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable library of the editing data `data` ([`VariableLibrary`]), for a file whose layers
|
||||
/// [`read`] can't read. Data without a `variableSets` element isn't read.
|
||||
pub fn variables(data: &[u8]) -> VariableLibrary {
|
||||
if super::find(data, b"variableSets").is_none() {
|
||||
return VariableLibrary::default();
|
||||
}
|
||||
let mut r = Reader::new(data);
|
||||
let _ = r.run();
|
||||
// A `/Document` still open when reading stopped.
|
||||
let open: Vec<Obj> = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect();
|
||||
for o in open.iter().filter(|o| o.ty == "Document") {
|
||||
variable_library(o, 0, &mut r.variables);
|
||||
}
|
||||
if r.variables != VariableLibrary::default() {
|
||||
return r.variables;
|
||||
}
|
||||
// Reading can stop before the library: read its node on its own.
|
||||
variable_sets_node(data).map_or(r.variables, |node| {
|
||||
let mut alone = b"%_/Document :\n%_/Dictionary :\n%_/Array :\n".to_vec();
|
||||
alone.extend_from_slice(node);
|
||||
alone.extend_from_slice(b"\n%_; (xmlnode-children) ,\n%_; /Recorded ,\n%_;\n");
|
||||
let mut r = Reader::new(&alone);
|
||||
let _ = r.run();
|
||||
r.variables
|
||||
})
|
||||
}
|
||||
|
||||
/// The `variableSets` XML node of the editing data `data`, from its `/XMLNode :` line to the line
|
||||
/// that closes it: found from its name back, counting the dictionaries and arrays it holds.
|
||||
fn variable_sets_node(data: &[u8]) -> Option<&[u8]> {
|
||||
const NAME: &[u8] = b"variableSets) /String (xmlnode-nodename) ,";
|
||||
let at = data.windows(NAME.len()).position(|w| w == NAME)?;
|
||||
let opens = |text: &[u8]| text.starts_with(b"/") && text.windows(2).any(|w| w == b" :");
|
||||
let closes = |text: &[u8]| text.starts_with(b";") || text.starts_with(b"/Def ;");
|
||||
let mut depth = 0usize;
|
||||
let mut line_end = data.get(..at)?.iter().rposition(|&b| b == b'\n' || b == b'\r')?;
|
||||
let start = loop {
|
||||
let line_start = data.get(..line_end)?.iter().rposition(|&b| b == b'\n' || b == b'\r').map_or(0, |at| at + 1);
|
||||
if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") {
|
||||
if closes(text) {
|
||||
depth += 1;
|
||||
} else if opens(text) {
|
||||
match depth.checked_sub(1) {
|
||||
Some(d) => depth = d,
|
||||
None => break line_start,
|
||||
}
|
||||
}
|
||||
}
|
||||
line_end = line_start.checked_sub(1)?;
|
||||
};
|
||||
let mut line_start = start;
|
||||
loop {
|
||||
let rest = data.get(line_start..)?;
|
||||
let line_end = line_start + rest.iter().position(|&b| b == b'\n' || b == b'\r').unwrap_or(rest.len());
|
||||
if let Some(text) = data.get(line_start..line_end)?.strip_prefix(b"%_") {
|
||||
if closes(text) {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return data.get(start..line_end);
|
||||
}
|
||||
} else if opens(text) {
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
if line_end >= data.len() {
|
||||
return None;
|
||||
}
|
||||
line_start = line_end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct GState {
|
||||
fill: Paint,
|
||||
@@ -347,6 +445,8 @@ struct Reader<'a> {
|
||||
to_doc: Option<Affine>,
|
||||
cropmarks: Option<Rect>,
|
||||
artboards: Vec<(String, Rect)>,
|
||||
/// The variables and data sets of the `/Document` dictionary's variable library.
|
||||
variables: VariableLibrary,
|
||||
gradients: HashMap<String, Gradient>,
|
||||
gradient_def: Option<GradientDef>,
|
||||
instance: Option<Instance>,
|
||||
@@ -508,6 +608,7 @@ impl<'a> Reader<'a> {
|
||||
to_doc: None,
|
||||
cropmarks: None,
|
||||
artboards: Vec::new(),
|
||||
variables: VariableLibrary::default(),
|
||||
gradients: HashMap::new(),
|
||||
gradient_def: None,
|
||||
instance: None,
|
||||
@@ -1501,8 +1602,10 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/Document` dictionary: the artboards.
|
||||
/// The `/Document` dictionary: the artboards, and how many variables and data sets its variable
|
||||
/// library has.
|
||||
fn document(&mut self, o: &Obj) {
|
||||
variable_library(o, 0, &mut self.variables);
|
||||
let Some(list) = find_entry(o, "ArtboardArray", 0) else { return };
|
||||
for (i, ab) in list.items().enumerate().take(1000) {
|
||||
let (p1, p2) = (ab.nums("PositionPoint1"), ab.nums("PositionPoint2"));
|
||||
@@ -2182,6 +2285,7 @@ impl<'a> Reader<'a> {
|
||||
self.drawn_looks
|
||||
));
|
||||
}
|
||||
warnings.extend(self.variables.note());
|
||||
Ok(Structure {
|
||||
doc,
|
||||
to_doc,
|
||||
@@ -2317,6 +2421,35 @@ fn find_entry<'o>(o: &'o Obj, key: &str, depth: usize) -> Option<&'o Obj> {
|
||||
o.obj(key).or_else(|| o.entries.iter().flat_map(|(_, v)| v.iter()).filter_map(V::obj).find_map(|c| find_entry(c, key, depth + 1)))
|
||||
}
|
||||
|
||||
/// Add the variables and data sets of the variable libraries in `o` or the dictionaries it holds
|
||||
/// (see [`VariableLibrary`]) to `found`.
|
||||
fn variable_library(o: &Obj, depth: usize, found: &mut VariableLibrary) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if o.ty == "XMLNode" && element_name(o).as_deref() == Some("variableSets") {
|
||||
for set in elements(o, "variableSet") {
|
||||
found.variables += elements(set, "variables").flat_map(|v| elements(v, "variable")).count();
|
||||
found.data_sets += elements(set, "sampleDataSets").flat_map(|d| elements(d, "sampleDataSet")).count();
|
||||
}
|
||||
return;
|
||||
}
|
||||
for c in o.items() {
|
||||
variable_library(c, depth + 1, found);
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the XML node `o` (its `xmlnode-nodename`) without a namespace prefix (`v:`).
|
||||
fn element_name(o: &Obj) -> Option<String> {
|
||||
let name = o.text("xmlnode-nodename")?;
|
||||
Some(name.rsplit(':').next().unwrap_or(&name).to_string())
|
||||
}
|
||||
|
||||
/// The child nodes named `name` (see [`element_name`]) of the XML node `o`.
|
||||
fn elements<'o>(o: &'o Obj, name: &'o str) -> impl Iterator<Item = &'o Obj> {
|
||||
o.obj("xmlnode-children").into_iter().flat_map(Obj::items).filter(move |c| c.ty == "XMLNode" && element_name(c).as_deref() == Some(name))
|
||||
}
|
||||
|
||||
/// A placed file's preview as a PNG, with its size: `w h rowBytes 32 n ` (each number followed by
|
||||
/// a space), then `h` rows of `rowBytes` bytes, premultiplied ARGB. `None` when it isn't that, is
|
||||
/// larger than [`MAX_PIXELS`] or is cut short.
|
||||
|
||||
@@ -538,6 +538,36 @@ fn artboards() {
|
||||
assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0));
|
||||
}
|
||||
|
||||
/// A variable library in the document data isn't read: a note gives how many variables and data
|
||||
/// sets it has, counting the elements of the library alone (a data set's element for a variable named
|
||||
/// `variable` isn't a variable).
|
||||
#[test]
|
||||
fn a_variable_library_is_noted_with_its_counts() {
|
||||
use vectorcraft_testkit::ai::variable_library;
|
||||
let square = layer(&format!("{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)));
|
||||
let notes = |library: &str| read_ok(&format!("{library}{square}")).warnings.into_iter().filter(|w| w.contains("variables")).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
notes(&variable_library(&["variable", "Team"], &["Home", "Away", "Third"])),
|
||||
["the file's variables and data sets weren't read (2 variables, 3 data sets): the Variables panel doesn't list them"]
|
||||
);
|
||||
assert_eq!(
|
||||
notes(&variable_library(&["Team"], &["Home"])),
|
||||
["the file's variables and data sets weren't read (1 variable, 1 data set): the Variables panel doesn't list them"]
|
||||
);
|
||||
assert!(notes(&variable_library(&[], &[])).is_empty(), "an empty library gives no warning");
|
||||
assert!(notes("").is_empty());
|
||||
// When the reader can't read the layers, `variables` still counts the library, for the warning
|
||||
// of the file read from its PDF part.
|
||||
let unread = stream(&format!("{}{}", variable_library(&["Team"], &["Home", "Away"]), layer("1 Zz\n")));
|
||||
assert!(read(unread.as_bytes()).is_err());
|
||||
assert_eq!(variables(unread.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 });
|
||||
// The same when the reading stops before it reaches the library (at an `LB` without its layer).
|
||||
let early = stream(&format!("LB\n{}{square}", variable_library(&["Team"], &["Home", "Away"])));
|
||||
assert!(read(early.as_bytes()).is_err());
|
||||
assert_eq!(variables(early.as_bytes()), read::VariableLibrary { variables: 1, data_sets: 2 });
|
||||
assert_eq!(variables(stream(&square).as_bytes()), read::VariableLibrary::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_look_stands_for_its_object() {
|
||||
// The look (a group, with a part on `%_` lines for newer readers), then the object on `%_`
|
||||
|
||||
@@ -550,6 +550,11 @@ impl Story {
|
||||
self.text.len()
|
||||
}
|
||||
|
||||
/// The story's characters, with `\r` ending paragraphs and `\x03` ending lines.
|
||||
pub(super) fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// The point frame's matrix (the first frame's, when it is one), else the identity.
|
||||
fn matrix(&self) -> [f64; 6] {
|
||||
match self.frames.first() {
|
||||
|
||||
@@ -30,7 +30,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, Scaling, TextKind};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, Scaling, TextKind, Unit};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use super::Imported;
|
||||
@@ -473,11 +473,17 @@ fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Do the regions of made type cover most of `object`?
|
||||
fn covered(object: &[Arc<Node>], content: &[Content]) -> bool {
|
||||
/// Do the regions of made type in the slots that `keep` takes cover most of `object`?
|
||||
fn covered(object: &[Arc<Node>], content: &[Content], keep: impl Fn(usize) -> bool) -> bool {
|
||||
let Some(r) = extent(object) else { return false };
|
||||
let inside: f64 =
|
||||
content.iter().filter_map(|c| if let Content::Made(_, Some(region), ..) = c { Some(region.intersect(r).area()) } else { None }).sum();
|
||||
let inside: f64 = content
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, c)| match c {
|
||||
Content::Made(_, Some(region), ..) => Some(region.intersect(r).area()).filter(|a| *a > 0.0 && keep(k)),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
inside >= 0.5 * r.area().max(1e-9)
|
||||
}
|
||||
|
||||
@@ -570,7 +576,7 @@ fn plan(
|
||||
// The file's text document is trusted where it agrees with the page about something (its lines
|
||||
// start where the page's type does, or the page has type where it lays type out), or where the
|
||||
// page has no type to disagree with.
|
||||
let in_region = |object: &[Arc<Node>]| covered(object, &content);
|
||||
let in_region = |object: &[Arc<Node>]| covered(object, &content, |_| true);
|
||||
let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o));
|
||||
if !trusted {
|
||||
content.iter_mut().for_each(|c| *c = Content::Empty);
|
||||
@@ -589,8 +595,33 @@ fn plan(
|
||||
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
|
||||
})
|
||||
};
|
||||
// The letters of each slot's story, in lower case.
|
||||
let spelled: Vec<String> = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect();
|
||||
// For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in
|
||||
// its story? All are false unless such slots have the letters of every text between them.
|
||||
let spelling = |object: &[Arc<Node>], content: &[Content]| -> Vec<bool> {
|
||||
let r = extent(object);
|
||||
let words: Vec<String> = object
|
||||
.iter()
|
||||
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None })
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect();
|
||||
let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str())));
|
||||
let near: Vec<bool> = content
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, c)| match (c, r) {
|
||||
(Content::Made(_, Some(region), ..), Some(r)) => region.intersect(r).area() > 0.0 && has(k),
|
||||
_ => false,
|
||||
})
|
||||
.collect();
|
||||
let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
|
||||
if every { near } else { vec![] }
|
||||
};
|
||||
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
|
||||
for (j, object) in shown.iter().enumerate() {
|
||||
let spells = spelling(object, &content);
|
||||
let spells = |k: usize| spells.get(k).copied().unwrap_or(false);
|
||||
match owner.get(j).copied().flatten().filter(|_| trusted) {
|
||||
// The type of a text object the layers hide: the layers have it, hidden, so the page's
|
||||
// isn't another text object's.
|
||||
@@ -601,12 +632,13 @@ fn plan(
|
||||
Some(c) => *c = Content::Page(object.clone()),
|
||||
None => {}
|
||||
},
|
||||
// What lies where made type is, is that type as the page draws it.
|
||||
None if covered(object, &content) => {
|
||||
// What lies where made type is and has that type's letters is that type as the page draws it.
|
||||
None if covered(object, &content, spells) => {
|
||||
let r = extent(object).unwrap_or(Rect::ZERO);
|
||||
for c in &mut content {
|
||||
for (k, c) in content.iter_mut().enumerate() {
|
||||
if let Content::Made(_, Some(region), seen, _) = c
|
||||
&& !region.intersect(r).is_zero_area()
|
||||
&& spells(k)
|
||||
{
|
||||
*seen = true;
|
||||
}
|
||||
@@ -994,7 +1026,18 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr
|
||||
// scale, which `to_doc` carries, so its art and the page reach the same size (#2031).
|
||||
let rect = to_doc.transform_rect_bbox(art_box);
|
||||
if (rect.width() - page_rect.width()).abs() > 0.5 || (rect.height() - page_rect.height()).abs() > 0.5 {
|
||||
return Err("its editing data is for a different page".into());
|
||||
// Both sizes in points as the fields show them, and the editing data's page to the
|
||||
// file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and
|
||||
// the scale doesn't round to 1:1.
|
||||
let n = |v: f64| Unit::Points.number(v);
|
||||
let (w, h, pw, ph) = (n(rect.width()), n(rect.height()), n(page_rect.width()), n(page_rect.height()));
|
||||
let k = page_rect.width() / rect.width();
|
||||
let factor = n(k.max(1.0 / k));
|
||||
if k.is_finite() && k > 0.0 && (rect.height() * k - page_rect.height()).abs() <= 0.5 && factor != "1" {
|
||||
let scale = if k >= 1.0 { format!("1:{factor}") } else { format!("{factor}:1") };
|
||||
return Err(format!("its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale}"));
|
||||
}
|
||||
return Err(format!("its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page"));
|
||||
}
|
||||
// The page's area in the layers' document, and the page's type moved there.
|
||||
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
|
||||
@@ -1308,17 +1351,26 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
/// its editing copy (`private`, the joined `AIPrivateData` or `AIPDFPrivateData` streams) instead,
|
||||
/// as [`layered`] reads an EPS: with the layers and objects of the file, its artboards (with the
|
||||
/// PDF part's bleed), and the art that lies outside them, which its PDF part doesn't have.
|
||||
/// `warnings` are the notes of reading the PDF part; the notes returned are those, and the layers'.
|
||||
/// The flag returned is `true` when the document is the one read from the layers; else the document
|
||||
/// is `visible`, its pages moved by `place_pages` when the layers can't be read. `outlined`: the
|
||||
/// PDF part's type was read as outlines (a file whose type shows then comes in as its PDF part).
|
||||
/// `warnings` are the notes of reading the PDF part; the notes returned are those, then the layers'
|
||||
/// notes, or a note saying why the layers weren't read when the editing data can't be decompressed
|
||||
/// or its layers can't be read. The flag returned is `true` when the document is the one read from
|
||||
/// the layers; else the document is `visible`, its pages moved by `place_pages` when the editing
|
||||
/// data was decompressed but its layers can't be read. `outlined`: the PDF part's type was read as
|
||||
/// outlines (a file whose type shows then comes in as its PDF part).
|
||||
/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning.
|
||||
/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`,
|
||||
/// by which its art came in scaled. Editing data whose artboard matches the page only in those
|
||||
/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from
|
||||
/// it.
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool, trust: bool, unit: f64) -> (Document, Vec<String>, bool) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings, false) };
|
||||
let unread = |mut all: Vec<String>, why: &str| {
|
||||
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
||||
all
|
||||
};
|
||||
let data = match ai::decode_private(private) {
|
||||
Ok(data) => data,
|
||||
Err(why) => return (visible, unread(warnings, &why), false),
|
||||
};
|
||||
if !has_layers(&data) {
|
||||
return (visible, warnings, false);
|
||||
}
|
||||
@@ -1330,10 +1382,10 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
(done, all, true)
|
||||
}
|
||||
Err(why) => {
|
||||
let mut all = warnings;
|
||||
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
||||
let mut visible = visible;
|
||||
place_pages(&ai::artboards(&data), &mut visible, unit);
|
||||
let mut all = unread(warnings, &why);
|
||||
all.extend(ai::variables(&data).note());
|
||||
(visible, all, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() {
|
||||
assert_eq!((stroke.width, dash.pattern, dash.offset), (20.0, vec![40.0, 20.0], 10.0));
|
||||
// Read as if its page had no `UserUnit`, the editing data is for another page.
|
||||
let (_, w, read) = crate::layered_ai(&private(&art), page.clone(), vec![], false, false, 1.0);
|
||||
assert!(!read && w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
assert!(!read && w.iter().any(|w| w.contains("for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10")), "{w:?}");
|
||||
// Editing data with the page in points.
|
||||
let points = native(&layer("Jig", true, "0 0 1 0 k\n100 100 m\n140 100 L\n140 140 L\n100 140 L\nf")).replace(" 0 0 100 100", " 0 0 1000 1000");
|
||||
let (d, w, read) = crate::layered_ai(&private(&points), page, vec![], false, false, 10.0);
|
||||
@@ -290,7 +290,66 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
|
||||
let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false, false, 1.0);
|
||||
assert_eq!(names(&d), names(&pdf));
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
assert!(w.iter().any(|w| w.contains("for a 50 × 50 pt page, the file's 100 × 100 pt page at 1:2")), "{w:?}");
|
||||
}
|
||||
|
||||
/// #2034: editing data for a page of another size gives both sizes in points, in an EPS's note and
|
||||
/// in a `.ai`'s.
|
||||
#[test]
|
||||
fn editing_data_for_a_page_of_another_size_names_both_sizes() {
|
||||
let art = native(&layer("Jig", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 600 400");
|
||||
let file = String::from_utf8(eps(&page_square(10, 10), &art)).unwrap().replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 612 792");
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
let note = "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 612 × 792 pt page): it comes in as its page, in one layer";
|
||||
assert!(r.warnings.iter().any(|w| w == note), "{:?}", r.warnings);
|
||||
// A `.ai` whose PDF part has an A4 page.
|
||||
let (_, w, read) = crate::layered_ai(&private(&art), Document::new(595.2756, 841.8898), vec![], false, false, 1.0);
|
||||
let note = "the file's layers weren't read from its editing data (its editing data is for a 600 × 400 pt page, not the file's 595.276 × 841.89 pt page): they come from its PDF part";
|
||||
assert!(!read && w.iter().any(|w| w == note), "{w:?}");
|
||||
}
|
||||
|
||||
/// #2034: editing data whose page is the file's at another scale (a Large Canvas file without its
|
||||
/// scale, for one) gives that scale, the editing data's page to the file's.
|
||||
#[test]
|
||||
fn editing_data_for_the_page_at_another_scale_names_the_scale() {
|
||||
let art = native(&layer("Jig", true, &square(10, 10)));
|
||||
let note = |file: &str| {
|
||||
let r = import(file.as_bytes()).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
r.warnings.into_iter().find(|w| w.contains("its editing data is for")).unwrap_or_default()
|
||||
};
|
||||
let file = String::from_utf8(eps(&page_square(10, 10), &art)).unwrap();
|
||||
let ten = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 1000 1000");
|
||||
assert_eq!(
|
||||
note(&ten),
|
||||
"the file's layers weren't read (its editing data is for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10): it comes in as its page, in one layer"
|
||||
);
|
||||
let wide = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 250 250");
|
||||
assert!(note(&wide).contains("for a 100 × 100 pt page, the file's 250 × 250 pt page at 1:2.5)"));
|
||||
let half = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 50 50");
|
||||
assert!(note(&half).contains("for a 100 × 100 pt page, the file's 50 × 50 pt page at 2:1)"));
|
||||
// A scale that rounds to 1:1 isn't given.
|
||||
let large = art.replace(" 0 0 100 100", " 0 0 2000 2000");
|
||||
let (_, w, _) = crate::layered_ai(&private(&large), Document::new(2000.8, 2000.8), vec![], false, false, 1.0);
|
||||
assert!(w.iter().any(|w| w.contains("for a 2000 × 2000 pt page, not the file's 2000.8 × 2000.8 pt page)")), "{w:?}");
|
||||
}
|
||||
|
||||
/// Editing data that can't be decompressed leaves the `.ai` as its PDF part, after its notes, with a
|
||||
/// note saying why its layers weren't read, which counts as a loss.
|
||||
#[test]
|
||||
fn an_ai_file_whose_editing_data_cant_be_decompressed_says_so() {
|
||||
let pdf = pdf_part(10.0, 86.0);
|
||||
for (private, why) in [
|
||||
(&b"%AI12_CompressedDataxx"[..], "its zlib compression is damaged"),
|
||||
(b"%AI24_ZStandard_Data nothing", "its Zstandard compression is damaged"),
|
||||
] {
|
||||
let (d, w, read) = crate::layered_ai(private, pdf.clone(), vec!["a note".into()], false, false, 1.0);
|
||||
assert!(!read);
|
||||
assert_eq!(names(&d), names(&pdf));
|
||||
assert_eq!(w, ["a note".to_string(), format!("the file's layers weren't read from its editing data ({why}): they come from its PDF part")]);
|
||||
assert!(crate::is_loss(&w[1]), "{w:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2031: a Large Canvas editing copy (`%AI24_LargeCanvasScale: 10`) writes its art and boxes at
|
||||
@@ -313,7 +372,7 @@ fn a_large_canvas_eps_keeps_its_layers_at_the_pages_size() {
|
||||
let art = native_large_canvas(20.0, 10.0, &layer("Art", true, &square(10, 10)));
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("for a 200 × 200 pt page, the file's 100 × 100 pt page at 2:1")), "{:?}", r.warnings);
|
||||
// A `.ai` keeps its art space: its PDF part is read at the size it declares.
|
||||
let art = native_large_canvas(100.0, 10.0, &layer("Art", true, &square(10, 10)));
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
|
||||
@@ -584,6 +643,86 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
|
||||
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Page type of no text object that lies where made type is, but has other letters, is not that type:
|
||||
/// it stays, with the nearest text object.
|
||||
#[test]
|
||||
fn type_of_the_page_near_made_type_with_other_letters_stays() {
|
||||
// "Extra" is 11.5 below the line of "Hi there", well inside the box round that line.
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 50 moveto (Extra) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(all, ["Hi there", "Extra"], "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Page type of no text object that lies where made type is and has its letters, in any case, is that
|
||||
/// type as the page draws it: the made type stays, and the page's is left out.
|
||||
#[test]
|
||||
fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() {
|
||||
// The page draws the line 3 points below where the story starts it, so no line of the story
|
||||
// owns it.
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
for drawn in ["Hi there", "HI THERE", "there"] {
|
||||
let page = format!("0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto ({drawn}) show");
|
||||
let r = import(&eps(&page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push((t.plain_text(), t.xf.as_coeffs()[5]));
|
||||
}
|
||||
});
|
||||
assert_eq!(all, [("Hi there".to_string(), 38.5)], "{drawn}: {:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{drawn}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Page type of no text object that lies where the made type of two text objects is, with the
|
||||
/// letters of one of them in each of its pieces, is the type of both as the page draws it.
|
||||
#[test]
|
||||
fn type_of_the_page_with_the_letters_of_two_made_types_where_it_lies_is_their_type() {
|
||||
// The two stories start 16.5 apart on one line, and the page draws both 3 points below it: its
|
||||
// two pieces are one object, which no line of either story owns.
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("there\r", 0, (8216.5, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (Hi) show 75 58.5 moveto (there) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push((t.plain_text(), t.xf.as_coeffs()[5]));
|
||||
}
|
||||
});
|
||||
assert_eq!(all, [("Hi".to_string(), 38.5), ("there".to_string(), 38.5)], "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The file's text document is trusted where the page has type where it lays type out, whatever
|
||||
/// letters that type has: its type is weighed against the page.
|
||||
#[test]
|
||||
fn page_type_where_made_type_is_trusts_the_text_document_whatever_its_letters() {
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 50 moveto (Extra) show";
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(all, ["Extra"], "{:?}", r.warnings);
|
||||
// "Hi there" was made from the text document and left out because the page doesn't draw it.
|
||||
assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() {
|
||||
// A file's page may draw what its layers have hidden since: story 1's type is on the page at
|
||||
@@ -1181,5 +1320,5 @@ fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() {
|
||||
// A wrong editing copy must still fail the page comparison.
|
||||
let wrong = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 120 100");
|
||||
let r = import(wrong.as_bytes()).unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("for a 100 × 100 pt page, not the file's 120 × 100 pt page")), "{:?}", r.warnings);
|
||||
}
|
||||
+24
-23
@@ -24,11 +24,12 @@
|
||||
//! corner), painted `N n F f S s B b` (lower case closes first); guides `(N) *` before and after
|
||||
//! the path (§6); fill rule `XR`; overprint `O`/`R`; stroke `w J j M d`.
|
||||
//! - Colours (§5.4): `g`/`G` (grey), `k`/`K` (CMYK), `Xa`/`XA` (`r g b`), `x`/`X` (a spot colour
|
||||
//! at a tint). Gradient fills (§3.5.3): the path, `Bb`, `1 (name) x y angle length 1 0 0 1 0 0
|
||||
//! Bg`, a radial's highlight `x y angle fraction Bh`, `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB`
|
||||
//! open) to stroke it too.
|
||||
//! - Images (§8): `%AI5_BeginRaster`, `[matrix] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in
|
||||
//! hex on comment lines, `%AI5_EndRaster`.
|
||||
//! at a tint). Gradient fills (§3.5.3): the path, `Bb`, a radial's highlight `x y angle fraction
|
||||
//! Bh` (`0 0 0 0 Bh` at the center, §3.5.5, §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`,
|
||||
//! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too.
|
||||
//! - Images (§8.1, §8.4): `%AI5_File:`, `%AI5_BeginRaster`,
|
||||
//! `[ a b c d tx ty ] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in hex on comment lines of 60
|
||||
//! digits, `%AI5_EndRaster`.
|
||||
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
|
||||
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
|
||||
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
|
||||
@@ -75,8 +76,8 @@ use crate::{latin, ps};
|
||||
const MAX_NEST: u32 = 8;
|
||||
/// Largest image written (pixels).
|
||||
const MAX_IMAGE_PIXELS: u64 = 1 << 26;
|
||||
/// Hex digits on a line of image data.
|
||||
const HEX_LINE: usize = 64;
|
||||
/// Hex digits on a line of image data (§8.4).
|
||||
const HEX_LINE: usize = 60;
|
||||
|
||||
pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)";
|
||||
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines";
|
||||
@@ -996,8 +997,9 @@ impl<'a> Writer<'a> {
|
||||
// ---------- gradients ----------
|
||||
|
||||
/// The definition gradient `g` uses (defined once for each distinct gradient), and its
|
||||
/// instance on a path of bounds `bounds`: `Bb`, `1 (name) x y angle length 1 0 0 1 0 0 Bg`, a
|
||||
/// radial's highlight `x y angle fraction Bh` (its distance a fraction of the radius, §3.5.5).
|
||||
/// instance on a path of bounds `bounds`: `Bb`, a radial's highlight `x y angle fraction Bh`
|
||||
/// (its distance a fraction of the radius, at most 1, §3.5.5; `0 0 0 0 Bh` at the center,
|
||||
/// §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`.
|
||||
fn gradient(&mut self, g: &GradientPaint, bounds: Rect) -> String {
|
||||
let name = self.define(&g.gradient, g.swatch.as_deref());
|
||||
for stop in &g.gradient.stops {
|
||||
@@ -1024,15 +1026,14 @@ impl<'a> Writer<'a> {
|
||||
self.warn(ELLIPTICAL);
|
||||
}
|
||||
let mut s = String::from("Bb\n");
|
||||
if g.gradient.kind == GradientKind::Radial
|
||||
&& let Some(f) = geom.focal
|
||||
{
|
||||
let off: Vec2 = self.to_art * f - start;
|
||||
if off.hypot() > 1e-9 && length > 1e-9 {
|
||||
let fraction = (off.hypot() / length).min(1.0);
|
||||
ps::push_nums(&mut s, &[off.x, off.y, off.y.atan2(off.x).to_degrees(), fraction]);
|
||||
s.push_str("Bh\n");
|
||||
}
|
||||
if g.gradient.kind == GradientKind::Radial {
|
||||
// Every radial instance has a highlight (§3.5.5), `0 0 0 0 Bh` at the center (§3.5.6).
|
||||
// It lies within the gradient's circle: one past the radius is written on it.
|
||||
let off: Vec2 = geom.focal.map_or(Vec2::ZERO, |f| self.to_art * f - start);
|
||||
let fraction = if off.hypot() > 1e-9 && length > 1e-9 { off.hypot() / length } else { 0.0 };
|
||||
let (off, angle) = if fraction > 0.0 { (off / fraction.max(1.0), off.y.atan2(off.x).to_degrees()) } else { (Vec2::ZERO, 0.0) };
|
||||
ps::push_nums(&mut s, &[off.x, off.y, angle, fraction.min(1.0)]);
|
||||
s.push_str("Bh\n");
|
||||
}
|
||||
s.push_str("1 ");
|
||||
s.push_str(&ps::string(&name));
|
||||
@@ -1223,9 +1224,10 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- images ----------
|
||||
|
||||
/// An image (§8): `%AI5_BeginRaster`, its matrix (an art-space matrix applied to the pixels
|
||||
/// with their rows running downwards, `(u, -v)`) and operands with `XI`, its RGB samples in hex
|
||||
/// on comment lines, `%AI5_EndRaster`.
|
||||
/// An image (§8): `%AI5_File:`, `%AI5_BeginRaster`, its matrix (an art-space matrix applied to
|
||||
/// the pixels with their rows running downwards, `(u, -v)`) as `[ a b c d tx ty ]` and its
|
||||
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
|
||||
/// `%AI5_EndRaster`.
|
||||
fn image(&mut self, im: &ImageObject) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = self.to_art * im.xf * flip;
|
||||
@@ -1275,9 +1277,8 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let s = &mut self.body;
|
||||
s.push_str("%AI5_File:\n%AI5_BeginRaster\n[");
|
||||
s.push_str("%AI5_File:\n%AI5_BeginRaster\n[ ");
|
||||
ps::push_nums(s, &[a, b, c, d, e, f]);
|
||||
s.pop();
|
||||
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
|
||||
s.push_str(&hex);
|
||||
s.push_str("%AI5_EndRaster\n");
|
||||
|
||||
@@ -89,8 +89,8 @@ fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
|
||||
/// don't print, its type as its glyphs' outlines where the writer writes them (all type with
|
||||
/// [`LegacyAiOptions::outline_text`], else the type 8-bit text can't hold) and left out where it
|
||||
/// writes text objects, its
|
||||
/// hidden objects shown when `o` writes them visible, and its gradients filled with their first
|
||||
/// stop's colour.
|
||||
/// hidden objects shown when `o` writes them visible, and its gradient fills and strokes painted
|
||||
/// in their first stop's color.
|
||||
fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
|
||||
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type).
|
||||
fn outlined(t: &vectorcraft_doc::TextObject) -> Node {
|
||||
@@ -120,10 +120,12 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
|
||||
n.visible = true;
|
||||
}
|
||||
for item in &mut n.appearance.items {
|
||||
if let AppearanceItem::Fill(f) = item
|
||||
&& let Paint::Gradient(g) = &f.paint
|
||||
{
|
||||
f.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
|
||||
let paint = match item {
|
||||
AppearanceItem::Fill(f) => &mut f.paint,
|
||||
AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if let Paint::Gradient(g) = paint {
|
||||
*paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
|
||||
}
|
||||
}
|
||||
if let Some(c) = n.children_mut() {
|
||||
@@ -267,6 +269,113 @@ fn a_rotated_image_keeps_its_place() {
|
||||
assert!(back.as_coeffs().iter().zip(xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{back:?}");
|
||||
}
|
||||
|
||||
/// A 16 × 16 RGB image of `doc` filling `r`.
|
||||
fn picture(doc: &mut Document, r: Rect) -> Node {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(16, 16, |u, v| image::Rgba([(u * 16) as u8, (v * 16) as u8, 128, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
doc.images.insert(key.clone(), blob);
|
||||
let xf = Affine::new([r.width() / 16.0, 0.0, 0.0, r.height() / 16.0, r.x0, r.y0]);
|
||||
Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 16, height: 16, xf, link: None, placement: Default::default() })))
|
||||
}
|
||||
|
||||
/// A layer `Below` holding a clipping group (a circle clipping an image, as in #2692), a group
|
||||
/// holding an image and an image; a layer `Above` holding an orange square. Without `images`,
|
||||
/// `Below` is empty.
|
||||
fn images_below_a_square(images: bool) -> Document {
|
||||
let mut doc = Document::new(400.0, 200.0);
|
||||
doc.layers.clear();
|
||||
let below = doc.add_layer(Some("Below"));
|
||||
let above = doc.add_layer(Some("Above"));
|
||||
if images {
|
||||
let clip = Node::new(
|
||||
doc.alloc_id(),
|
||||
NodeKind::Path {
|
||||
path: shapes::ellipse(Rect::new(30.0, 30.0, 170.0, 170.0)),
|
||||
rule: FillRule::NonZero,
|
||||
live: None,
|
||||
clipping: true,
|
||||
guide: false,
|
||||
},
|
||||
);
|
||||
let clipped = picture(&mut doc, Rect::new(20.0, 20.0, 180.0, 180.0));
|
||||
let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true });
|
||||
doc.insert(Some(below), usize::MAX, window).unwrap();
|
||||
let grouped = picture(&mut doc, Rect::new(200.0, 20.0, 240.0, 60.0));
|
||||
let group = Node::group(doc.alloc_id(), vec![Arc::new(grouped)]);
|
||||
doc.insert(Some(below), usize::MAX, group).unwrap();
|
||||
let loose = picture(&mut doc, Rect::new(250.0, 20.0, 290.0, 60.0));
|
||||
doc.insert(Some(below), usize::MAX, loose).unwrap();
|
||||
}
|
||||
let orange = Paint::solid(Color::rgb(247.0 / 255.0, 148.0 / 255.0, 29.0 / 255.0));
|
||||
let square = shape(&mut doc, shapes::rectangle(Rect::new(220.0, 100.0, 380.0, 180.0)), orange, Paint::None);
|
||||
doc.insert(Some(above), usize::MAX, square).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`.
|
||||
fn rasters(text: &str) -> Vec<Vec<&str>> {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = vec![];
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
if *l == "%AI5_File:" {
|
||||
let end = lines[i..].iter().position(|l| *l == "%AI5_EndRaster").map(|n| i + n).unwrap();
|
||||
out.push(lines[i..=end].to_vec());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every image, in a clipping group, a group or neither, is written as §8.1's syntax and §8.4's
|
||||
/// examples write one: `%AI5_File:`, `%AI5_BeginRaster`, the matrix with a space inside each
|
||||
/// bracket and the operands on the `XI` line, the samples in hex on comment lines of 60 digits,
|
||||
/// `%AI5_EndRaster`. The art after the images is written as it is without them, and the file
|
||||
/// reads back with its layers, clipping group, images and square (#2692).
|
||||
#[test]
|
||||
fn images_are_written_in_the_form_the_specification_shows() {
|
||||
let out = write(&images_below_a_square(true));
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
let images = rasters(&text);
|
||||
assert_eq!(images.len(), 3, "{text}");
|
||||
for lines in &images {
|
||||
let [file, begin, xi, data @ .., end] = lines.as_slice() else { panic!("{lines:?}") };
|
||||
assert_eq!((*file, *begin, *end), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster"));
|
||||
let matrix = xi.strip_prefix("[ ").and_then(|l| l.strip_suffix(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI")).unwrap_or_else(|| panic!("{xi}"));
|
||||
assert_eq!(matrix.split(' ').filter(|v| v.parse::<f64>().is_ok()).count(), 6, "{xi}");
|
||||
let digits: Vec<&str> = data.iter().map(|l| l.strip_prefix('%').unwrap_or_else(|| panic!("{l}"))).collect();
|
||||
let (last, full) = digits.split_last().unwrap();
|
||||
assert!(full.iter().all(|d| d.len() == 60) && (1..=60).contains(&last.len()), "{digits:?}");
|
||||
assert!(digits.iter().all(|d| d.bytes().all(|b| b.is_ascii_hexdigit())), "{digits:?}");
|
||||
assert_eq!(digits.concat().len(), 16 * 16 * 3 * 2);
|
||||
}
|
||||
// The clipping group: `q`, the clipping path painted `h W n` (§5.6.2), the image, `Q`; the
|
||||
// group: `u`, the image, `U`.
|
||||
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\n"), "{text}");
|
||||
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nU\n"), "{text}");
|
||||
let layer = |t: &str| -> String {
|
||||
let at = t.find("(Above) Ln\n").unwrap();
|
||||
t[at..].split("%AI5_EndLayer--").next().unwrap().to_string()
|
||||
};
|
||||
let alone = String::from_utf8(write(&images_below_a_square(false)).bytes).unwrap();
|
||||
assert_eq!(layer(&text), layer(&alone));
|
||||
let doc = crate::import(&out.bytes).unwrap().document;
|
||||
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
||||
assert_eq!(names, ["Below", "Above"]);
|
||||
let below = children(&doc.layers[0]);
|
||||
assert_eq!(below.len(), 3, "{below:#?}");
|
||||
let NodeKind::Group { children: window, clip: true } = &below[0].kind else { panic!("{:?}", below[0].kind) };
|
||||
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(window[1].kind, NodeKind::Image(_)), "{window:#?}");
|
||||
assert!(matches!(&below[1].kind, NodeKind::Group { children, clip: false } if matches!(children[0].kind, NodeKind::Image(_))));
|
||||
assert!(matches!(below[2].kind, NodeKind::Image(_)));
|
||||
let above = children(&doc.layers[1]);
|
||||
assert_eq!(above.len(), 1);
|
||||
let rgb = above[0].appearance.fill_paint().color().map(|c| c.to_rgb()).unwrap();
|
||||
assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_fill_and_its_stroke_are_one_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
@@ -294,6 +403,137 @@ fn a_gradient_fill_and_its_stroke_are_one_object() {
|
||||
assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
/// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted
|
||||
/// to the object).
|
||||
fn white_to_navy(kind: GradientKind, geom: Option<vectorcraft_color::GradientGeom>) -> Paint {
|
||||
let navy = Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0);
|
||||
let g = Gradient { kind, stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, navy)] };
|
||||
Paint::Gradient(Box::new(GradientPaint { geom, ..GradientPaint::new(g) }))
|
||||
}
|
||||
|
||||
/// A 400 × 400 document: #2687's radial circle (center 100, 100, radius 80) and linear square;
|
||||
/// below them a radial square in a group, a radial compound ring, a clipping group whose clipping
|
||||
/// path and member are radial, a square stroked radial, and a radial square whose elliptical
|
||||
/// gradient has its focal point 30 pt above the center, past its 20 pt radius.
|
||||
fn radials() -> Document {
|
||||
let mut doc = Document::new(400.0, 400.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let radial = || white_to_navy(GradientKind::Radial, None);
|
||||
let circle = shape(&mut doc, shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 180.0)), radial(), Paint::None);
|
||||
let square = shape(&mut doc, shapes::rectangle(Rect::new(220.0, 20.0, 380.0, 180.0)), white_to_navy(GradientKind::Linear, None), Paint::None);
|
||||
let member = shape(&mut doc, shapes::rectangle(Rect::new(20.0, 220.0, 60.0, 260.0)), radial(), Paint::None);
|
||||
let group = Node::group(doc.alloc_id(), vec![Arc::new(member)]);
|
||||
let outer = shape(&mut doc, shapes::rectangle(Rect::new(80.0, 220.0, 120.0, 260.0)), Paint::None, Paint::None);
|
||||
let inner = shape(&mut doc, shapes::rectangle(Rect::new(90.0, 230.0, 110.0, 250.0)), Paint::None, Paint::None);
|
||||
let mut ring = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd });
|
||||
ring.appearance = Appearance::basic(radial(), Paint::None, 0.0).into();
|
||||
let mut clip = Node::new(
|
||||
doc.alloc_id(),
|
||||
NodeKind::Path {
|
||||
path: shapes::rectangle(Rect::new(140.0, 220.0, 180.0, 260.0)),
|
||||
rule: FillRule::NonZero,
|
||||
live: None,
|
||||
clipping: true,
|
||||
guide: false,
|
||||
},
|
||||
);
|
||||
clip.appearance = Appearance::basic(radial(), Paint::None, 0.0).into();
|
||||
let clipped = shape(&mut doc, shapes::rectangle(Rect::new(130.0, 210.0, 190.0, 270.0)), radial(), Paint::None);
|
||||
let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true });
|
||||
let stroked = shape(&mut doc, shapes::rectangle(Rect::new(200.0, 220.0, 240.0, 260.0)), Paint::None, radial());
|
||||
let geom = vectorcraft_color::GradientGeom {
|
||||
start: vectorcraft_geom::Point::new(300.0, 240.0),
|
||||
end: vectorcraft_geom::Point::new(320.0, 240.0),
|
||||
aspect: 2.0,
|
||||
focal: Some(vectorcraft_geom::Point::new(300.0, 210.0)),
|
||||
};
|
||||
let off_center =
|
||||
shape(&mut doc, shapes::rectangle(Rect::new(260.0, 220.0, 340.0, 260.0)), white_to_navy(GradientKind::Radial, Some(geom)), Paint::None);
|
||||
for n in [circle, square, group, ring, window, stroked, off_center] {
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// Every radial gradient instance has its highlight `Bh` (§3.5.5): `0 0 0 0 Bh` when it is at the
|
||||
/// center (#2687: without it the reference app drew the circle in its outer color), on fills,
|
||||
/// strokes, in groups, compound paths and clipping groups, and an off-center one within the
|
||||
/// gradient's circle. Linear instances have none. VectorCraft reads back one linear and at least
|
||||
/// seven radial gradients, each with its stops, and the circle, the square and the off-center
|
||||
/// square in their place.
|
||||
#[test]
|
||||
fn radial_gradients_have_their_highlight() {
|
||||
let out = write(&radials());
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
// One definition for the radial gradient, one for the linear one (§3.5.2).
|
||||
assert!(text.contains("2 Bn\n%AI5_BeginGradient: (Gradient)\n(Gradient) 1 2 Bd\n[\n"), "{text}");
|
||||
assert!(text.contains("%AI5_BeginGradient: (Gradient 2)\n(Gradient 2) 0 2 Bd\n[\n"), "{text}");
|
||||
// Each instance, `Bb` to `BB`.
|
||||
let instances: Vec<&str> = text.split("Bb\n").skip(1).filter_map(|t| t.split_inclusive("BB\n").next()).collect();
|
||||
let radial: Vec<&str> = instances.iter().copied().filter(|i| i.contains(" (Gradient) ")).collect();
|
||||
let linear: Vec<&str> = instances.iter().copied().filter(|i| i.contains(" (Gradient 2) ")).collect();
|
||||
assert_eq!((radial.len() + linear.len(), linear.len()), (instances.len(), 1), "{instances:#?}");
|
||||
assert!(radial.len() >= 8, "{radial:#?}");
|
||||
for i in &radial {
|
||||
let bh = i.lines().next().unwrap_or_default();
|
||||
assert!(bh.ends_with(" Bh") && bh.split_whitespace().count() == 5, "{i}");
|
||||
}
|
||||
assert!(!linear[0].contains("Bh"), "{}", linear[0]);
|
||||
for needle in [
|
||||
// #2687's circle and square.
|
||||
"\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 300 0 80 1 0 0 1 0 0 Bg\nf\n0 BB\n",
|
||||
"\nBb\n1 (Gradient 2) 220 300 0 160 1 0 0 1 0 0 Bg\nf\n0 BB\n",
|
||||
// In a group.
|
||||
"\nBb\n0 0 0 0 Bh\n1 (Gradient) 40 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n",
|
||||
// Both subpaths of the compound ring (§5.3.5).
|
||||
"\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n90 170 m\n",
|
||||
"\nBb\n0 0 0 0 Bh\n1 (Gradient) 100 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n*U\n",
|
||||
// The clipping path's fill and the clipped square (§5.6.2).
|
||||
"\nBb\n0 0 0 0 Bh\n1 (Gradient) 160 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n",
|
||||
"\nBb\n0 0 0 0 Bh\n1 (Gradient) 160 160 0 30 1 0 0 1 0 0 Bg\nf\n0 BB\nQ\n",
|
||||
// The stroke, written as its outline filled.
|
||||
"\nBb\n0 0 0 0 Bh\n1 (Gradient) 220 160 0 21 1 0 0 1 0 0 Bg\nf\n0 BB\n",
|
||||
// The focal point 30 pt up, 1.5 radii out: written on the circle, straight up.
|
||||
"\nBb\n0 20 90 1 Bh\n1 (Gradient) 300 160 0 20 1 0 0 1 0 0 Bg\nf\n0 BB\n",
|
||||
] {
|
||||
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
|
||||
}
|
||||
for expected in [STROKES, ELLIPTICAL] {
|
||||
assert!(out.warnings.iter().any(|w| w == expected), "{expected} not in {:?}", out.warnings);
|
||||
}
|
||||
// Read back: every gradient keeps its stops, the document holds the linear square and at
|
||||
// least seven radial gradients, and the circle, the square and the off-center square keep
|
||||
// their place.
|
||||
let doc = crate::import(&out.bytes).unwrap().document;
|
||||
let mut found = vec![];
|
||||
doc.walk(|n| {
|
||||
for i in &n.appearance.items {
|
||||
if let AppearanceItem::Fill(f) = i
|
||||
&& let Paint::Gradient(g) = &f.paint
|
||||
{
|
||||
found.push((**g).clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
let kinds = |k: GradientKind| found.iter().filter(|g| g.gradient.kind == k).count();
|
||||
// Seven radial objects (a compound path's members carry its fill too) and the linear square.
|
||||
assert!(kinds(GradientKind::Radial) >= 7 && kinds(GradientKind::Linear) == 1, "{found:#?}");
|
||||
let near = |a: vectorcraft_geom::Point, x: f64, y: f64| (a.x - x).abs() < 1e-3 && (a.y - y).abs() < 1e-3;
|
||||
for g in &found {
|
||||
let [white, navy] = g.gradient.stops.as_slice() else { panic!("{g:?}") };
|
||||
assert_eq!((white.offset, navy.offset, white.color), (0.0, 1.0, Color::rgb(1.0, 1.0, 1.0)), "{g:?}");
|
||||
let rgb = navy.color.to_rgb();
|
||||
assert!([27.0, 42.0, 107.0].iter().zip(rgb).all(|(want, got)| (want / 255.0 - got).abs() < 1e-3), "{rgb:?}");
|
||||
}
|
||||
let placed = |x: f64, y: f64| found.iter().filter_map(|g| g.geom.map(|m| (g.gradient.kind, m))).find(|(_, m)| near(m.start, x, y));
|
||||
let Some((GradientKind::Radial, circle)) = placed(100.0, 100.0) else { panic!("{found:#?}") };
|
||||
assert!(near(circle.end, 180.0, 100.0) && circle.focal.is_none(), "{circle:?}");
|
||||
let Some((GradientKind::Linear, square)) = placed(220.0, 100.0) else { panic!("{found:#?}") };
|
||||
assert!(near(square.end, 380.0, 100.0), "{square:?}");
|
||||
let Some((GradientKind::Radial, off)) = placed(300.0, 240.0) else { panic!("{found:#?}") };
|
||||
assert!(near(off.end, 320.0, 240.0) && off.focal.is_some_and(|f| near(f, 300.0, 220.0)), "{off:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_back_with_layers_groups_and_paths() {
|
||||
let out = write(&sample());
|
||||
|
||||
+100
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, Point, Rect, Shape};
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveExtrema, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Shape};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::EPS;
|
||||
@@ -482,7 +482,9 @@ impl PathData {
|
||||
}
|
||||
Self { subpaths }
|
||||
}
|
||||
/// Tight geometric bounds (curve extrema, not control points).
|
||||
/// Tight geometric bounds (curve extrema, not control points), measured by [`curve_bounds`]: a
|
||||
/// segment with a control point that is not finite or past `f64::MAX / 16` in x or y is measured
|
||||
/// by its control points.
|
||||
pub fn bounds(&self) -> Option<Rect> {
|
||||
if self.is_empty() {
|
||||
return None;
|
||||
@@ -493,7 +495,7 @@ impl PathData {
|
||||
{
|
||||
return Some(Rect::from_points(a.p, a.p));
|
||||
}
|
||||
Some(bp.bounding_box())
|
||||
Some(curve_bounds(&bp))
|
||||
}
|
||||
/// Bounds including control handles (used for quick culling).
|
||||
pub fn control_bounds(&self) -> Option<Rect> {
|
||||
@@ -550,6 +552,36 @@ impl PathData {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest x or y, in size, of a segment's control points for which [`segment_bounds`]
|
||||
/// measures the segment by its extrema. kurbo adds up to eight coordinates together when it finds a
|
||||
/// segment's extrema and evaluates the segment there, and a sum of eight coordinates of this size or
|
||||
/// smaller is finite.
|
||||
const EXTREMA_LIMIT: f64 = f64::MAX / 16.0;
|
||||
|
||||
/// The box of `path`'s segments, each measured by [`segment_bounds`] and joined in order as kurbo's
|
||||
/// `bounding_box` joins them. A path with no segments gives the empty rect at the origin, as kurbo
|
||||
/// does.
|
||||
pub fn curve_bounds(path: &BezPath) -> Rect {
|
||||
path.segments().map(segment_bounds).reduce(|bb, seg_bb| bb.union(seg_bb)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The box of `seg` as kurbo's `bounding_box` measures it (its start, end and extrema), except
|
||||
/// that a segment with a control point whose x or y is not finite or is larger in size than
|
||||
/// `f64::MAX / 16` is measured by its control points. The box then holds every point the segment
|
||||
/// draws, and it reaches infinity when a control point is infinite.
|
||||
pub fn segment_bounds(seg: PathSeg) -> Rect {
|
||||
let pts = match seg {
|
||||
PathSeg::Line(l) => [l.p0, l.p1, l.p1, l.p1],
|
||||
PathSeg::Quad(q) => [q.p0, q.p1, q.p2, q.p2],
|
||||
PathSeg::Cubic(c) => [c.p0, c.p1, c.p2, c.p3],
|
||||
};
|
||||
if pts.iter().all(|p| p.x.abs() <= EXTREMA_LIMIT && p.y.abs() <= EXTREMA_LIMIT) {
|
||||
ParamCurveExtrema::bounding_box(&seg)
|
||||
} else {
|
||||
pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, &p| r.union_pt(p))
|
||||
}
|
||||
}
|
||||
|
||||
fn push_curve(cur: &mut Option<SubPath>, last: Point, c1: Point, c2: Point, p: Point) {
|
||||
let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false });
|
||||
if let Some(a) = sp.anchors.last_mut() {
|
||||
@@ -654,6 +686,71 @@ mod tests {
|
||||
assert_eq!(p.bounds(), Some(Rect::new(5.0, 5.0, 15.0, 15.0)));
|
||||
}
|
||||
|
||||
/// A curve from (100, 100) to (300, 100) with the given out and in handles.
|
||||
fn curve(h_out: Point, h_in: Point) -> PathData {
|
||||
let a = Anchor::with_handles(Point::new(100.0, 100.0), Point::new(100.0, 100.0), h_out);
|
||||
let b = Anchor::with_handles(Point::new(300.0, 100.0), h_in, Point::new(300.0, 100.0));
|
||||
PathData::single(SubPath::new(vec![a, b], false))
|
||||
}
|
||||
|
||||
/// A curve whose handles are at ±1e308 or infinite is measured by its control points: its box
|
||||
/// holds every finite point of the curve, and an infinite handle makes the box infinite. Handles
|
||||
/// at ±`f64::MAX / 16` are measured by the curve's extrema, and that box holds the curve too.
|
||||
#[test]
|
||||
fn bounds_hold_a_curve_whose_handles_are_huge() {
|
||||
// `eval` rounds: a point may sit a few ulps outside the box.
|
||||
let within = |v: f64, lo: f64, hi: f64| {
|
||||
let e = 1e-12 * v.abs().max(1.0);
|
||||
lo - e <= v && v <= hi + e
|
||||
};
|
||||
let holds_curve = |h_out: Point, h_in: Point, b: Rect| {
|
||||
let c = CubicBez::new(Point::new(100.0, 100.0), h_out, h_in, Point::new(300.0, 100.0));
|
||||
for i in 0..=20 {
|
||||
let q = c.eval(f64::from(i) / 20.0);
|
||||
if q.is_finite() {
|
||||
assert!(within(q.x, b.x0, b.x1) && within(q.y, b.y0, b.y1), "{h_out:?} {h_in:?}: {q:?} is outside {b:?}");
|
||||
}
|
||||
}
|
||||
};
|
||||
let cases = [
|
||||
(Point::new(100.0, 1e308), Point::new(300.0, 1e308)),
|
||||
(Point::new(100.0, -1e308), Point::new(300.0, 1e308)),
|
||||
(Point::new(-1.7e308, 100.0), Point::new(1.7e308, 100.0)),
|
||||
(Point::new(f64::INFINITY, 100.0), Point::new(250.0, 100.0)),
|
||||
];
|
||||
for (h_out, h_in) in cases {
|
||||
let p = curve(h_out, h_in);
|
||||
let b = p.bounds().unwrap();
|
||||
assert_eq!(Some(b), p.control_bounds(), "{h_out:?} {h_in:?}");
|
||||
holds_curve(h_out, h_in, b);
|
||||
}
|
||||
assert!(!curve(Point::new(f64::INFINITY, 100.0), Point::new(250.0, 100.0)).bounds().unwrap().x1.is_finite());
|
||||
let m = f64::MAX / 16.0;
|
||||
for (h_out, h_in) in [(Point::new(100.0, m), Point::new(300.0, m)), (Point::new(100.0, -m), Point::new(300.0, m))] {
|
||||
let b = curve(h_out, h_in).bounds().unwrap();
|
||||
assert!(b.y0.is_finite() && b.y1.is_finite() && b.y1 < m, "{h_out:?} {h_in:?}: {b:?}");
|
||||
holds_curve(h_out, h_in, b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ordinary paths, and curves whose handles are large but below `f64::MAX / 16`, measure as
|
||||
/// kurbo's `bounding_box` measures them, bit for bit.
|
||||
#[test]
|
||||
fn bounds_of_ordinary_paths_match_kurbo_bit_for_bit() {
|
||||
let circle = PathData::from_bezpath(&kurbo::Circle::new((50.0, 50.0), 20.0).to_path(1e-3));
|
||||
let s_curve = curve(Point::new(180.0, -40.0), Point::new(220.0, 240.0));
|
||||
let mut two = square();
|
||||
two.subpaths.extend(circle.transformed(Affine::translate((-75.5, 30.25))).subpaths);
|
||||
let lone = PathData::new(vec![SubPath::new(vec![Anchor::corner(Point::new(1.0, 2.0))], false), square().subpaths[0].clone()]);
|
||||
let quad = PathData::from_bezpath(&BezPath::from_svg("M0 0 Q 50 -80 100 0 Q 150 80 200 0").unwrap());
|
||||
for p in [square(), circle, s_curve, two, lone, quad, curve(Point::new(100.0, 1e300), Point::new(300.0, -1e300))] {
|
||||
let ours = p.bounds().unwrap();
|
||||
let kurbo = p.to_bezpath().bounding_box();
|
||||
let bits = |r: Rect| [r.x0, r.y0, r.x1, r.y1].map(f64::to_bits);
|
||||
assert_eq!(bits(ours), bits(kurbo), "{ours:?} {kurbo:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_detection() {
|
||||
let a = Anchor::with_handles(Point::new(0.0, 0.0), Point::new(-1.0, 0.0), Point::new(2.0, 0.0));
|
||||
|
||||
@@ -215,13 +215,24 @@ pub(crate) fn read_loop(shared: &Shared, out: &SyncSender<String>, mut reader: i
|
||||
}
|
||||
|
||||
/// A reply that is already known: in order behind queued work, or at once when the engine is
|
||||
/// tied up for long (then nobody can expect order).
|
||||
/// tied up for long (then nobody can expect order). Like a message, it waits while
|
||||
/// [`MAX_QUEUED`] lines are queued.
|
||||
fn submit_canned(shared: &Shared, out: &SyncSender<String>, reply: String) {
|
||||
let mut st = lock(&shared.state);
|
||||
if shared.long_running(&st).is_some() {
|
||||
drop(st);
|
||||
let _ = out.send(reply);
|
||||
return;
|
||||
loop {
|
||||
if st.closed {
|
||||
return;
|
||||
}
|
||||
if shared.long_running(&st).is_some() {
|
||||
drop(st);
|
||||
let _ = out.send(reply);
|
||||
return;
|
||||
}
|
||||
if st.queue.len() < MAX_QUEUED {
|
||||
break;
|
||||
}
|
||||
// Wake now and then: the call ahead may turn long-running while the queue is full.
|
||||
st = shared.wake.wait_timeout(st, Duration::from_millis(50)).unwrap_or_else(PoisonError::into_inner).0;
|
||||
}
|
||||
st.queue.push_back(Job::Canned(reply));
|
||||
shared.wake.notify_all();
|
||||
@@ -307,3 +318,37 @@ fn cancel(shared: &Shared, params: &Value) {
|
||||
shared.wake.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_errors_wait_for_room_in_the_queue() {
|
||||
// No call has run past the busy limit, so the replies queue rather than go out at once.
|
||||
let shared = Shared::new(Arc::new(AtomicBool::new(false)), Duration::from_secs(60));
|
||||
let (out, _replies) = std::sync::mpsc::sync_channel(1);
|
||||
let lines = 3 * MAX_QUEUED;
|
||||
let input = "not json\n".repeat(lines);
|
||||
std::thread::scope(|scope| {
|
||||
let reader = scope.spawn(|| read_loop(&shared, &out, input.as_bytes()));
|
||||
// Nothing takes jobs off the queue yet, so the reader stops at the limit.
|
||||
let end = Instant::now() + Duration::from_secs(10);
|
||||
while !reader.is_finished() && lock(&shared.state).queue.len() < MAX_QUEUED && Instant::now() < end {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let queued = lock(&shared.state).queue.len();
|
||||
assert!(queued <= MAX_QUEUED, "{queued} lines queued, more than {MAX_QUEUED}");
|
||||
// Once the engine takes jobs, every line is answered with its parse error.
|
||||
let mut answered = 0;
|
||||
while let Some(job) = shared.next() {
|
||||
assert!(matches!(&job, Job::Canned(r) if r.contains("parse error")));
|
||||
answered += 1;
|
||||
shared.finished();
|
||||
}
|
||||
assert_eq!(answered, lines);
|
||||
reader.join().unwrap().unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
+65
-13
@@ -38,10 +38,26 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
|
||||
/// Like [`export`], also returning warnings: options accepted but not applied yet (see
|
||||
/// [`crate::PdfSettings::warnings`]) and features approximated or dropped.
|
||||
pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
|
||||
write::guard(|| export_with_report_inner(doc, opts))
|
||||
write::guard(|| leaving_out_undecodable(|left_out| export_with_report_inner(doc, opts, left_out)))
|
||||
}
|
||||
|
||||
fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
|
||||
/// Run `write` (a whole PDF written by a [`Writer`]), and run it again each time it fails on an
|
||||
/// image whose pixel data the writer couldn't decode, with that image left out: [`Writer::finish`]
|
||||
/// adds the image's key to the set `write` is given, and the exporters leave out the images it
|
||||
/// holds. The writer reports such an image (a PNG, GIF or WebP kept as it is, whose header reads)
|
||||
/// only when it finishes the file. Each repeated run leaves out at least one more image.
|
||||
pub(crate) fn leaving_out_undecodable<T>(mut write: impl FnMut(&mut HashSet<String>) -> Result<T, PdfError>) -> Result<T, PdfError> {
|
||||
let mut left_out = HashSet::new();
|
||||
loop {
|
||||
let known = left_out.len();
|
||||
match write(&mut left_out) {
|
||||
Err(_) if left_out.len() > known => {}
|
||||
done => return done,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut HashSet<String>) -> Result<ExportReport, PdfError> {
|
||||
let set = &opts.settings;
|
||||
set.check()?;
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
@@ -53,14 +69,16 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
let pages = pages(doc, opts)?;
|
||||
|
||||
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
|
||||
let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
|
||||
// The time of writing (none on the web) dates the editing data and the page information.
|
||||
let now = vectorcraft_doc::metadata::now_unix();
|
||||
let created_at = opts.created.or(now);
|
||||
crate::pdfx::require_date(set.standard, created_at)?;
|
||||
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
|
||||
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
|
||||
let mut warnings = set.warnings();
|
||||
let native = opts.native.as_deref().filter(|_| set.preserve_editing);
|
||||
if let Some(native) = native {
|
||||
w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created_at.map(date_time)));
|
||||
w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time)));
|
||||
} else if set.preserve_editing {
|
||||
warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
|
||||
}
|
||||
@@ -68,6 +86,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
let mut ex = Exporter::new(doc, set);
|
||||
ex.non_printing = set.include_non_printing || set.create_layers;
|
||||
ex.out = w.out.clone();
|
||||
ex.left_out = left_out.clone();
|
||||
// A knockout page group draws the layers' objects one by one, not layer by layer.
|
||||
ex.layers = set.writes_layers() && !doc.page_knockout;
|
||||
if set.writes_layers() && doc.page_knockout {
|
||||
@@ -86,7 +105,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1));
|
||||
let (m, r) = (boxes.media, boxes.bleed);
|
||||
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
|
||||
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() };
|
||||
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
|
||||
// The page is the media box, y down from its top-left corner (like ours), in units of
|
||||
// `unit` points.
|
||||
@@ -113,8 +132,13 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions) -> Result<ExportR
|
||||
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
|
||||
let (layers, overprinted) = (ex.layers, ex.overprinted);
|
||||
w.absorb(ex);
|
||||
let (bytes, more) = w.finish()?;
|
||||
let (bytes, more) = w.finish(left_out)?;
|
||||
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
|
||||
// The writer gives the file its creation date as its modification date too.
|
||||
let bytes = match opts.modified.filter(|m| Some(*m) != created_at) {
|
||||
Some(modified) => crate::post::modification_date(bytes, modified)?,
|
||||
None => bytes,
|
||||
};
|
||||
warnings.extend(more);
|
||||
let bytes = crate::post::user_units(bytes, &units)?;
|
||||
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?;
|
||||
@@ -207,6 +231,9 @@ pub(crate) struct Writer {
|
||||
rgb_in_cmyk: RgbInCmyk,
|
||||
/// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups.
|
||||
pdf13: bool,
|
||||
/// The document's images the exporters wrote, by key, gathered from the exporters
|
||||
/// ([`Self::absorb`]): [`Self::finish`] finds the key of an image the writer couldn't decode.
|
||||
images: Vec<(String, Image)>,
|
||||
}
|
||||
|
||||
/// One page as [`Writer::page`] draws it: the art placed in a drawing space ([`Self::place`]),
|
||||
@@ -288,6 +315,7 @@ impl Writer {
|
||||
warnings,
|
||||
rgb_in_cmyk: RgbInCmyk::default(),
|
||||
pdf13: set.pdf13(),
|
||||
images: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -370,7 +398,7 @@ impl Writer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take an exporter's warnings and Lab spot colours once its pages are drawn.
|
||||
/// Take an exporter's warnings, Lab spot colours and images once its pages are drawn.
|
||||
pub(crate) fn absorb(&mut self, ex: Exporter) {
|
||||
for w in ex.warnings {
|
||||
if !self.warnings.contains(&w) {
|
||||
@@ -383,13 +411,28 @@ impl Writer {
|
||||
self.lab_spots.push(spot);
|
||||
}
|
||||
}
|
||||
// The exporter caches images by their key, followed by their size and mask after a NUL.
|
||||
for (cache, image) in ex.images {
|
||||
if let (Some(key), Some(image)) = (cache.split('\0').next(), image) {
|
||||
self.images.push((key.to_string(), image));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The finished file (with the output intent and Trapped entries, and checked against its
|
||||
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
|
||||
/// alternates.
|
||||
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let mut bytes = self.pdf.finish()?;
|
||||
/// alternates. When the writer couldn't decode one of the images, its key is added to
|
||||
/// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned.
|
||||
pub(crate) fn finish(mut self, left_out: &mut HashSet<String>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
|
||||
let images = std::mem::take(&mut self.images);
|
||||
let mut bytes = self.pdf.finish().map_err(|e| {
|
||||
if let write::WriteError::Serialization(k) = &e
|
||||
&& let krilla::error::KrillaError::Image(failed, ..) = &**k
|
||||
{
|
||||
left_out.extend(images.iter().filter(|(_, image)| image == failed).map(|(key, _)| key.clone()));
|
||||
}
|
||||
PdfError::from(e)
|
||||
})?;
|
||||
if self.cmyk {
|
||||
cmyk_blending(&mut bytes, self.out.tagged);
|
||||
}
|
||||
@@ -557,11 +600,18 @@ pub(crate) struct Exporter<'a> {
|
||||
/// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in
|
||||
/// text…): deeper ones are left out.
|
||||
inline_depth: u32,
|
||||
/// Keys of the images left out because the writer couldn't decode them
|
||||
/// ([`leaving_out_undecodable`]).
|
||||
pub left_out: HashSet<String>,
|
||||
}
|
||||
|
||||
/// How deep inline graphics nest before they are left out.
|
||||
const MAX_INLINE_DEPTH: u32 = 4;
|
||||
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
impl<'a> Exporter<'a> {
|
||||
/// An exporter of `doc` writing images as `set` says; layers whose Print option is off are
|
||||
/// left out.
|
||||
@@ -590,6 +640,7 @@ impl<'a> Exporter<'a> {
|
||||
overprint: false,
|
||||
overprinted: false,
|
||||
inline_depth: 0,
|
||||
left_out: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1696,7 +1747,8 @@ impl Exporter<'_> {
|
||||
|
||||
/// Image `key` placed `size` points wide and high, resampled and compressed as the Compression
|
||||
/// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images);
|
||||
/// cached by key (and size when images are resampled).
|
||||
/// cached by key (and size when images are resampled). `None`, with [`UNDECODED`] among the
|
||||
/// warnings, for an image that is missing, can't be decoded or is in [`Self::left_out`].
|
||||
fn load_image(&mut self, key: &str, size: (f64, f64)) -> Option<Image> {
|
||||
let c = self.compression;
|
||||
let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None);
|
||||
@@ -1707,7 +1759,7 @@ impl Exporter<'_> {
|
||||
let interpolate = self.interpolate;
|
||||
let doc = self.doc;
|
||||
let out = self.out.clone();
|
||||
let img = doc.images.get(key).and_then(|blob| {
|
||||
let img = doc.images.get(key).filter(|_| !self.left_out.contains(key)).and_then(|blob| {
|
||||
let cmyk_image = blob.is_cmyk();
|
||||
if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk {
|
||||
self.rgb_in_cmyk.images.insert(key.to_string());
|
||||
@@ -1723,7 +1775,7 @@ impl Exporter<'_> {
|
||||
r.image.or_else(|| embed(blob, interpolate))
|
||||
});
|
||||
if img.is_none() {
|
||||
self.warn(format!("image '{key}' could not be decoded and was skipped"));
|
||||
self.warn(UNDECODED);
|
||||
}
|
||||
self.images.insert(cache, img.clone());
|
||||
img
|
||||
|
||||
+53
-29
@@ -11,6 +11,8 @@ use crate::import_scan::text_string;
|
||||
|
||||
/// The longest XMP packet read, in bytes once decoded; a longer one is left out.
|
||||
const MAX_XMP: usize = 8 << 20;
|
||||
/// The deepest the elements of an XMP packet may nest; a packet nested deeper is left out.
|
||||
const MAX_NESTING: usize = 256;
|
||||
|
||||
const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
|
||||
const XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
@@ -50,7 +52,7 @@ pub fn file_info(bytes: &[u8], password: Option<&str>) -> Option<FileInfo> {
|
||||
pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo {
|
||||
let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default();
|
||||
let info = pdf.metadata();
|
||||
let text = |b: &Option<Vec<u8>>| b.as_deref().map(info_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let text = |b: &Option<Vec<u8>>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
f.author = f.author.or_else(|| text(&info.author));
|
||||
f.description = f.description.or_else(|| text(&info.subject));
|
||||
f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty()));
|
||||
@@ -59,33 +61,6 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo {
|
||||
f
|
||||
}
|
||||
|
||||
/// An information dictionary string: UTF-16 or UTF-8 with its byte order mark, as [`text_string`]
|
||||
/// reads it, else PDFDocEncoding (ISO 32000-1 Annex D, Table D.2).
|
||||
fn info_string(b: &[u8]) -> String {
|
||||
if b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||
return text_string(b);
|
||||
}
|
||||
b.iter().map(|&c| pdf_doc_char(c)).collect()
|
||||
}
|
||||
|
||||
/// Byte `c` of a PDFDocEncoding string. It matches Latin-1 except at 0x18 to 0x1F (accents) and
|
||||
/// 0x80 to 0xA0 (punctuation, ligatures, letters and the euro sign); 0x9F has no character and
|
||||
/// stays as it is.
|
||||
fn pdf_doc_char(c: u8) -> char {
|
||||
const ACCENTS: [char; 8] = ['\u{2D8}', '\u{2C7}', '\u{2C6}', '\u{2D9}', '\u{2DD}', '\u{2DB}', '\u{2DA}', '\u{2DC}'];
|
||||
const HIGH: [char; 33] = [
|
||||
'\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{192}', '\u{2044}', '\u{2039}', '\u{203A}', '\u{2212}',
|
||||
'\u{2030}', '\u{201E}', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}', '\u{141}', '\u{152}',
|
||||
'\u{160}', '\u{178}', '\u{17D}', '\u{131}', '\u{142}', '\u{153}', '\u{161}', '\u{17E}', '\u{9F}', '\u{20AC}',
|
||||
];
|
||||
let mapped = match c {
|
||||
0x18..=0x1F => ACCENTS.get(usize::from(c - 0x18)),
|
||||
0x80..=0xA0 => HIGH.get(usize::from(c - 0x80)),
|
||||
_ => None,
|
||||
};
|
||||
mapped.copied().unwrap_or(char::from(c))
|
||||
}
|
||||
|
||||
/// The catalog's metadata stream, decoded; `None` past [`MAX_XMP`] bytes.
|
||||
fn packet(pdf: &Pdf) -> Option<Vec<u8>> {
|
||||
let xref = pdf.xref();
|
||||
@@ -94,9 +69,14 @@ fn packet(pdf: &Pdf) -> Option<Vec<u8>> {
|
||||
(data.len() <= MAX_XMP).then(|| data.into_owned())
|
||||
}
|
||||
|
||||
/// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16.
|
||||
/// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16, or when its
|
||||
/// elements nest more than [`MAX_NESTING`] deep.
|
||||
fn xmp(packet: &[u8]) -> Option<FileInfo> {
|
||||
let text = xml_text(packet)?;
|
||||
// roxmltree parses nested elements recursively.
|
||||
if nesting(&text) > MAX_NESTING {
|
||||
return None;
|
||||
}
|
||||
let xml = roxmltree::Document::parse(&text).ok()?;
|
||||
// The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value.
|
||||
let descriptions: Vec<Node<'_, '_>> = xml
|
||||
@@ -139,6 +119,50 @@ fn xml_text(b: &[u8]) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 values of property `ns` `name` on the first of `descriptions` that gives it: an attribute
|
||||
/// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out.
|
||||
fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String> {
|
||||
|
||||
@@ -211,7 +211,7 @@ fn usage(d: &Dict<'_>, category: &[u8], key: &[u8]) -> Option<bool> {
|
||||
}
|
||||
|
||||
/// A PDF text string: UTF-16 (with its byte order mark), UTF-8 (with one) or PDFDocEncoding
|
||||
/// (read as Latin-1, which it matches for letters).
|
||||
/// (ISO 32000-1 Annex D, Table D.2).
|
||||
pub(crate) fn text_string(b: &[u8]) -> String {
|
||||
if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) {
|
||||
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
|
||||
@@ -220,7 +220,25 @@ pub(crate) fn text_string(b: &[u8]) -> String {
|
||||
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
b.iter().map(|&c| c as char).collect()
|
||||
b.iter().map(|&c| pdf_doc_char(c)).collect()
|
||||
}
|
||||
|
||||
/// Byte `c` of a PDFDocEncoding string. It matches Latin-1 except at 0x18 to 0x1F (accents) and
|
||||
/// 0x80 to 0xA0 (punctuation, ligatures, letters and the euro sign); 0x9F has no character and
|
||||
/// stays as it is.
|
||||
fn pdf_doc_char(c: u8) -> char {
|
||||
const ACCENTS: [char; 8] = ['\u{2D8}', '\u{2C7}', '\u{2C6}', '\u{2D9}', '\u{2DD}', '\u{2DB}', '\u{2DA}', '\u{2DC}'];
|
||||
const HIGH: [char; 33] = [
|
||||
'\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{192}', '\u{2044}', '\u{2039}', '\u{203A}', '\u{2212}',
|
||||
'\u{2030}', '\u{201E}', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}', '\u{141}', '\u{152}',
|
||||
'\u{160}', '\u{178}', '\u{17D}', '\u{131}', '\u{142}', '\u{153}', '\u{161}', '\u{17E}', '\u{9F}', '\u{20AC}',
|
||||
];
|
||||
let mapped = match c {
|
||||
0x18..=0x1F => ACCENTS.get(usize::from(c - 0x18)),
|
||||
0x80..=0xA0 => HIGH.get(usize::from(c - 0x80)),
|
||||
_ => None,
|
||||
};
|
||||
mapped.copied().unwrap_or(char::from(c))
|
||||
}
|
||||
|
||||
/// One page as the interpreter walks it.
|
||||
|
||||
@@ -95,6 +95,8 @@ pub struct PdfOptions {
|
||||
pub title: Option<String>,
|
||||
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
|
||||
pub created: Option<i64>,
|
||||
/// Modification date as Unix seconds (UTC); `None` = the creation date.
|
||||
pub modified: Option<i64>,
|
||||
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
|
||||
pub native: Option<Vec<u8>>,
|
||||
/// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer
|
||||
|
||||
+46
-2
@@ -2,8 +2,9 @@
|
||||
//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised
|
||||
//! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes
|
||||
//! through between its two steps so that it stays linearised. Also here: what makes a file say it
|
||||
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, and the pages'
|
||||
//! user units ([`user_units`]), which the export writes before the thumbnails.
|
||||
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, the file's
|
||||
//! modification date ([`modification_date`]) and the pages' user units ([`user_units`]), which the
|
||||
//! export writes in that order before the thumbnails.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -77,6 +78,49 @@ pub(crate) fn user_units(pdf: Vec<u8>, units: &[f64]) -> Result<Vec<u8>, PdfErro
|
||||
patch.apply(&pdf, &xref)
|
||||
}
|
||||
|
||||
/// `pdf` dated as modified at `modified` (Unix seconds, UTC), its creation dates kept: the
|
||||
/// document information's `/ModDate` (a PDF date, ISO 32000-1 §7.9.4) and every `xmp:ModifyDate`,
|
||||
/// `xmp:MetadataDate` and `stEvt:when` of the catalog's XMP metadata (ISO 8601 dates), in UTC as
|
||||
/// the PDF writer writes the creation date. The writer gives `/ModDate`, `xmp:ModifyDate` and
|
||||
/// `stEvt:when` the creation date.
|
||||
pub(crate) fn modification_date(pdf: Vec<u8>, modified: i64) -> Result<Vec<u8>, PdfError> {
|
||||
let failed = |why: &str| PdfError::Write(format!("can't write the file's modification date: {why}"));
|
||||
let Parsed { xref, objects, trailer, root, .. } = linearize::parse(&pdf).map_err(failed)?;
|
||||
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(modified);
|
||||
let mut patch = Patch::new(&xref);
|
||||
if let Some(&Obj::Ref(n, _)) = trailer.get(b"Info")
|
||||
&& let Some(Obj::Str { start, end, .. }) = objects.get(&n).and_then(|o| o.value.get(b"ModDate"))
|
||||
{
|
||||
patch.replace(*start, *end, format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)").into_bytes());
|
||||
}
|
||||
let metadata = match objects.get(&root).and_then(|c| c.value.get(b"Metadata")) {
|
||||
Some(&Obj::Ref(n, _)) => objects.get(&n),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(o) = metadata
|
||||
&& let Some(&Obj::Int { value, start, end }) = o.value.get(b"Length")
|
||||
{
|
||||
let date = format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}+00:00");
|
||||
let xmp = pdf.get(..o.endobj).unwrap_or_default();
|
||||
let mut grown = 0;
|
||||
for tag in ["xmp:ModifyDate", "xmp:MetadataDate", "stEvt:when"] {
|
||||
let (open, close) = (format!("<{tag}>"), format!("</{tag}>"));
|
||||
let mut from = o.value_span.1;
|
||||
while let Some(at) = crate::lab_spot::find(xmp, open.as_bytes(), from).map(|i| i + open.len())
|
||||
&& let Some(to) = crate::lab_spot::find(xmp, close.as_bytes(), at)
|
||||
{
|
||||
grown += date.len() as i64 - (to - at) as i64;
|
||||
patch.replace(at, to, date.clone().into_bytes());
|
||||
from = to + close.len();
|
||||
}
|
||||
}
|
||||
if grown != 0 {
|
||||
patch.replace(start, end, (value + grown).to_string().into_bytes());
|
||||
}
|
||||
}
|
||||
patch.apply(&pdf, &xref)
|
||||
}
|
||||
|
||||
/// Linearise `pdf`, encrypting it on the way as `set` says.
|
||||
fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
|
||||
let (prepared, plan) = linearize::prepare(pdf)?;
|
||||
|
||||
@@ -30,6 +30,7 @@ mod settings;
|
||||
mod tiling;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, Rect};
|
||||
@@ -44,7 +45,7 @@ pub use plates::print_inks;
|
||||
pub use settings::*;
|
||||
pub use tiling::{TilingPage, tiling};
|
||||
|
||||
use crate::export::{Exporter, Sheet, Writer};
|
||||
use crate::export::{Exporter, Sheet, Writer, leaving_out_undecodable};
|
||||
use crate::marks::PageBoxes;
|
||||
use crate::{AdvancedSettings, ColorConversion, CompressionSettings, OutputSettings, Overprint, PdfError, PdfSettings};
|
||||
use layout::Layout;
|
||||
@@ -391,10 +392,10 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
|
||||
|
||||
/// Print `doc` to a PDF as `opts` say.
|
||||
pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
|
||||
craft_pdf::write::guard(|| print_inner(doc, opts))
|
||||
craft_pdf::write::guard(|| leaving_out_undecodable(|left_out| print_inner(doc, opts, left_out)))
|
||||
}
|
||||
|
||||
fn print_inner(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> {
|
||||
fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<String>) -> Result<PrintReport, PdfError> {
|
||||
let plan = plan(doc, opts)?;
|
||||
let doc = &*plan.doc;
|
||||
let separations = opts.settings.output.mode == OutputMode::Separations;
|
||||
@@ -413,6 +414,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfEr
|
||||
ex.out = w.out.clone();
|
||||
// Composite output keeps overprints as the Advanced option says (plates darken them).
|
||||
ex.overprint = !separations && pdf.advanced.overprint == Overprint::Preserve;
|
||||
ex.left_out = left_out.clone();
|
||||
}
|
||||
for &i in &plan.order {
|
||||
let Some(s) = plan.sheets.get(i) else { continue };
|
||||
@@ -423,7 +425,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfEr
|
||||
for ex in exporters {
|
||||
w.absorb(ex);
|
||||
}
|
||||
let (bytes, more) = w.finish()?;
|
||||
let (bytes, more) = w.finish(left_out)?;
|
||||
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted)?;
|
||||
let mut warnings = plan.warnings;
|
||||
for m in more {
|
||||
|
||||
@@ -538,6 +538,53 @@ fn image_roundtrip() {
|
||||
assert_eq!(decoded.get_pixel(3, 0).0, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
/// A 4 × 2 red image encoded as `format`, cut short `keep` bytes past the first `tag` in it: its
|
||||
/// header reads, and its pixel data can't be decoded.
|
||||
fn cut_image(format: image::ImageFormat, tag: &[u8], keep: usize) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
|
||||
let at = out.windows(tag.len()).position(|w| w == tag).expect("tag");
|
||||
out.truncate(at + tag.len() + keep);
|
||||
out
|
||||
}
|
||||
|
||||
/// An image whose pixel data can't be decoded (a PNG, GIF or WebP whose header reads) is left out
|
||||
/// of the PDF and of a print job, with one note however many there are, and the rest of the page is
|
||||
/// written.
|
||||
#[test]
|
||||
fn an_image_whose_pixels_cant_be_decoded_is_left_out_with_one_note() {
|
||||
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
let mut d = doc(200.0, 300.0);
|
||||
let blobs = [
|
||||
("good", ImageBlob::new("image/png", tiny_png())),
|
||||
("png", ImageBlob::new("image/png", cut_image(image::ImageFormat::Png, b"IDAT", 4))),
|
||||
("png2", ImageBlob::new("image/png", cut_image(image::ImageFormat::Png, b"IDAT", 6))),
|
||||
("gif", ImageBlob::new("image/gif", cut_image(image::ImageFormat::Gif, b"GIF89a", 7))),
|
||||
("webp", ImageBlob::new("image/webp", cut_image(image::ImageFormat::WebP, b"VP8L", 9))),
|
||||
];
|
||||
for (i, (key, blob)) in blobs.into_iter().enumerate() {
|
||||
d.images.insert(key.into(), blob);
|
||||
let xf = Affine::translate((20.0, 20.0 + 50.0 * i as f64)) * Affine::scale(10.0);
|
||||
let im = ImageObject { key: key.into(), width: 4, height: 2, xf, link: None, placement: Default::default() };
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im))));
|
||||
}
|
||||
add(&mut d, rect_node(Rect::new(100.0, 100.0, 150.0, 150.0), Color::BLACK));
|
||||
let written = |bytes: &[u8]| {
|
||||
let back = import(bytes).expect("import");
|
||||
let l = leaves(&back);
|
||||
let images: Vec<Rect> = l.iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).filter_map(|n| n.geometric_bounds()).collect();
|
||||
(images, l.iter().filter(|n| n.path_data().is_some()).count())
|
||||
};
|
||||
let r = export_with_report(&d, &PdfOptions::default()).expect("export");
|
||||
assert_eq!(r.warnings, [NOTE]);
|
||||
let (images, paths) = written(&r.bytes);
|
||||
assert!(images.len() == 1 && close(images[0], Rect::new(20.0, 20.0, 60.0, 40.0), 0.01), "{images:?}");
|
||||
assert_eq!(paths, 1);
|
||||
let p = print(&d, &PrintOptions::default()).expect("print");
|
||||
assert_eq!(p.warnings.iter().filter(|w| *w == NOTE).count(), 1, "{:?}", p.warnings);
|
||||
assert_eq!(written(&p.bytes).0.len(), 1);
|
||||
}
|
||||
|
||||
/// #2023: a group's Transform effect copies an image among its members as it does the paths: the
|
||||
/// PDF draws the image three times, where the effect put it.
|
||||
#[test]
|
||||
@@ -778,6 +825,30 @@ fn creation_date_written() {
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("D:20240229123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_modification_date_is_written_beside_the_creation_date() {
|
||||
let d = doc(100.0, 100.0);
|
||||
// 2020-01-01 00:00:00 and 2026-10-17 14:03:00 UTC.
|
||||
let (created, modified) = (1_577_836_800, 1_792_245_780);
|
||||
for standard in [Standard::None, Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4, Standard::PdfA2b] {
|
||||
let mut o = PdfOptions { created: Some(created), modified: Some(modified), ..PdfOptions::uncompressed() };
|
||||
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
|
||||
let bytes = export(&d, &o).unwrap();
|
||||
let t = String::from_utf8_lossy(&bytes);
|
||||
assert!(t.contains("/CreationDate(D:20200101000000Z)") && t.contains("/ModDate(D:20261017140300Z)"), "{standard:?}: document information");
|
||||
let info = file_info(&bytes, None).unwrap();
|
||||
assert_eq!((info.created, info.modified), (Some(created), Some(modified)), "{standard:?}: XMP");
|
||||
// PDF/A files record when they were saved and converted: at the time of writing too.
|
||||
assert_eq!(t.contains("<stEvt:when>2026-10-17T14:03:00"), standard == Standard::PdfA2b, "{standard:?}");
|
||||
assert!(!t.contains("<stEvt:when>2020"), "{standard:?}");
|
||||
assert_eq!(import(&bytes).unwrap().artboards.len(), 1, "{standard:?}: the file reads back");
|
||||
}
|
||||
// Without a modification date, the file is modified when it was created.
|
||||
let bytes = export(&d, &PdfOptions { created: Some(created), ..PdfOptions::uncompressed() }).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/ModDate(D:20200101000000Z)"));
|
||||
assert_eq!(file_info(&bytes, None).unwrap().modified, Some(created));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mask_exports_as_luminosity_soft_mask() {
|
||||
let mut d = doc(100.0, 100.0);
|
||||
|
||||
@@ -49,6 +49,17 @@ fn the_native_document_comes_back_intact() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_documents_modification_date_is_the_time_of_writing() {
|
||||
// Created in 1970: the information dictionary's dates say so, the embedded file's doesn't.
|
||||
let year = |t: Option<i64>| vectorcraft_doc::metadata::civil(t.unwrap())[0];
|
||||
let before = year(vectorcraft_doc::metadata::now_unix());
|
||||
let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned();
|
||||
let after = year(vectorcraft_doc::metadata::now_unix());
|
||||
assert!(text.contains("/CreationDate(D:1970"), "the file's creation date");
|
||||
assert!([before, after].iter().any(|y| text.contains(&format!("/ModDate(D:{y:04}"))), "the embedded file is dated when it is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_pages_are_noticed() {
|
||||
let r = pdf_with(Some(b"native"), false);
|
||||
|
||||
@@ -173,6 +173,27 @@ fn an_unreadable_xmp_packet_leaves_the_information_dictionary() {
|
||||
assert_eq!(file_info(b"%PDF-1.7\nnot a PDF", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() {
|
||||
// The creator's `rdf:li` is 6 deep (`x:xmpmeta`, `rdf:RDF`, `rdf:Description`, `dc:creator`,
|
||||
// `rdf:Seq`); `n` elements around its text nest the packet 6 + `n` deep.
|
||||
let nested = |n: usize| {
|
||||
let creator = format!("<dc:creator><rdf:Seq><rdf:li>{}Ada Lovelace{}</rdf:li></rdf:Seq></dc:creator>", "<a>".repeat(n), "</a>".repeat(n));
|
||||
packet(&description("", &creator))
|
||||
};
|
||||
let author = |xmp: &str| file_info(&pdf(Some(INFO), Some(xmp)), None).unwrap().author;
|
||||
assert_eq!(author(&nested(250)).as_deref(), Some("Ada Lovelace"));
|
||||
assert_eq!(author(&nested(251)).as_deref(), Some("Grace Hopper"));
|
||||
// A million deep: 7 MB, within the longest packet read.
|
||||
let f = file_info(&pdf(Some(INFO), Some(&nested(1_000_000))), None).unwrap();
|
||||
assert_eq!(f.author.as_deref(), Some("Grace Hopper"));
|
||||
assert_eq!((f.created, f.modified), (Some(CREATED), Some(MODIFIED)));
|
||||
// Tags in comments, CDATA sections and processing instructions don't count.
|
||||
let tags = "<a>".repeat(300);
|
||||
let creator = format!("<!-- {tags} --><dc:creator><![CDATA[{tags}]]><?pi {tags}?><rdf:Seq><rdf:li>Ada Lovelace</rdf:li></rdf:Seq></dc:creator>");
|
||||
assert_eq!(author(&packet(&description("", &creator))).as_deref(), Some("Ada Lovelace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_pdf_gives_its_file_info_back() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -230,6 +230,14 @@ fn visibility(layers: &[std::sync::Arc<Node>]) -> Vec<bool> {
|
||||
layers.iter().map(|l| l.visible).collect()
|
||||
}
|
||||
|
||||
/// A group name without a byte order mark is PDFDocEncoding (ISO 32000-1 Annex D, Table D.2),
|
||||
/// which differs from Latin-1 at 0x18 to 0x1F and 0x80 to 0xA0.
|
||||
#[test]
|
||||
fn layer_names_without_a_byte_order_mark_are_pdf_doc_encoding() {
|
||||
let bytes = three_groups([&ocg(r"Art\205final", ""), &ocg(r"\215Caf\351\216 \240", ""), &ocg("Plain", "")], &[], "");
|
||||
assert_eq!(names(&import(&bytes).unwrap()), ["Art\u{2013}final", "\u{201C}Caf\u{E9}\u{201D} \u{20AC}", "Plain"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_view_state_that_is_off_hides_the_layer_with_its_art() {
|
||||
let off = ocg("Off", "/Usage << /View << /ViewState /OFF >> >>");
|
||||
|
||||
@@ -211,3 +211,34 @@ fn page_information_names_the_file_artboard_and_date() {
|
||||
let band_end = top + 100.0 + 6.0 + PrinterMarks::LENGTH;
|
||||
assert!(glyphs.iter().all(|g| g.y0 > band_end && g.x0 >= top - 0.5), "{glyphs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
|
||||
let d = doc();
|
||||
// The outlines of the page information of a PDF of `d` created at `created`.
|
||||
let info = |created: i64| {
|
||||
let opts = PdfOptions { settings: settings(json!({"marks": {"pageInfo": true}})), created: Some(created), ..PdfOptions::uncompressed() };
|
||||
let back = import(&export_with_report(&d, &opts).unwrap().bytes).unwrap();
|
||||
let mut out = vec![];
|
||||
back.walk(|n| {
|
||||
if n.appearance.fill().is_some_and(|f| f.paint.is_registration())
|
||||
&& let Some(p) = n.path_data()
|
||||
{
|
||||
out.push(p.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let now = || vectorcraft_doc::metadata::now_unix().unwrap();
|
||||
// Both print the minute they are written in: try again when the minute turns between them.
|
||||
for _ in 0..3 {
|
||||
let minute = now() / 60;
|
||||
let (old, new) = (info(1_577_836_800), info(now()));
|
||||
if now() / 60 == minute {
|
||||
assert!(!old.is_empty());
|
||||
assert!(old == new, "a file created in 2020 prints the same date as one created now");
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the minute turned during each of three tries");
|
||||
}
|
||||
@@ -219,7 +219,11 @@ pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option<Rect> {
|
||||
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect)))
|
||||
}
|
||||
|
||||
fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
/// Bounds of what an export draws of object `n` ([`art_bounds`]): `None` when `n` is hidden, a
|
||||
/// guide or a template layer. The hidden objects, guides and template layers in it are left out,
|
||||
/// except in a group or layer with effects of its own that keep all its members (`without_undrawn`
|
||||
/// lists them). Symbol instances are measured by their art in `symbols`.
|
||||
pub fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
|
||||
+57
-11
@@ -144,8 +144,10 @@ struct Kept {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum KeptImage {
|
||||
/// The image on its own grid, as [`Renderer::rasterize_image`] makes it.
|
||||
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>),
|
||||
/// The image on its own grid, as [`Renderer::rasterize_image`] makes it, and how many images
|
||||
/// in the content failed to decode while it was made ([`crate::paint::decode_failures`]):
|
||||
/// each draw of the image from here counts them again ([`crate::paint::add_decode_failures`]).
|
||||
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>, u64),
|
||||
/// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the
|
||||
/// Rasterize applied, as the first draw made it, and the transform from its pixels to
|
||||
/// document space.
|
||||
@@ -1757,13 +1759,17 @@ impl Renderer {
|
||||
Some(RasterFx::Rasterize(r)) => {
|
||||
let rect = r.image(content.bounds.unwrap_or(content.reach), line);
|
||||
let kept = content.kept.and_then(|k| match &*k.image.borrow() {
|
||||
Some(KeptImage::Grid(image)) => Some(image.clone()),
|
||||
Some(KeptImage::Grid(image, failed)) => {
|
||||
crate::paint::add_decode_failures(*failed);
|
||||
Some(image.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let image = kept.or_else(|| {
|
||||
let failures = crate::paint::decode_failures();
|
||||
let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new);
|
||||
if let (Some(k), Some(image)) = (content.kept, &image) {
|
||||
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone()));
|
||||
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures));
|
||||
}
|
||||
image
|
||||
});
|
||||
@@ -2383,12 +2389,13 @@ mod tests {
|
||||
}
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut frames = vec![];
|
||||
for threads in [Some(0), Some(1), Some(4), Some(8)] {
|
||||
crate::set_default_threads(threads);
|
||||
// The test sets each renderer's count. `set_default_threads` would also change the count of the
|
||||
// renderers that other tests create while this one runs.
|
||||
for threads in [0, 1, 4, 8] {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
frames.push(r.render(&d, 1200, 900, Affine::scale(6.0), &opts).pixels);
|
||||
}
|
||||
crate::set_default_threads(None);
|
||||
assert!(frames.windows(2).all(|w| w[0] == w[1]), "the thread count changed pixels");
|
||||
}
|
||||
|
||||
@@ -2757,6 +2764,38 @@ mod tests {
|
||||
assert_eq!(r.pixel_fx.len(), 1, "the later frames draw from the raster kept");
|
||||
}
|
||||
|
||||
/// An image that can't be decoded, with Rasterize, a drop shadow and a blur: the shadow draws
|
||||
/// the Rasterize image first, and the blurred content draws the image kept from that draw
|
||||
/// ([`super::Kept`]). Every frame counts the failure, also when the shadow and the blurred
|
||||
/// content are drawn from the rasters kept.
|
||||
#[test]
|
||||
fn rasters_drawn_from_a_kept_rasterize_image_count_the_images_it_could_not_decode() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
// A PNG signature with nothing after it.
|
||||
d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
|
||||
let im =
|
||||
ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() };
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.appearance.effects = [
|
||||
("rasterize", json!({"background": "white"})),
|
||||
("stylize.dropShadow", json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})),
|
||||
("blur.gaussian", json!({"radius": 4.0})),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, params)| Effect { id: id.into(), params, visible: true })
|
||||
.collect();
|
||||
d.insert(Some(d.layers[0].id), 0, n).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
for frame in 0..3 {
|
||||
let before = crate::paint::decode_failures();
|
||||
r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert!(crate::paint::decode_failures() > before, "frame {frame}");
|
||||
}
|
||||
assert_eq!((r.shadows.len(), r.pixel_fx.len()), (1, 1), "the later frames draw from the rasters kept");
|
||||
}
|
||||
|
||||
/// While interacting, an object that only moved draws its drop shadow and filtered content
|
||||
/// from the rasters of an earlier frame, at the nearest whole pixel, without running the
|
||||
/// effects again (#1257). The frame after the interaction runs them again: it is what a fresh
|
||||
@@ -2814,8 +2853,15 @@ mod tests {
|
||||
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();
|
||||
// Every renderer here has 3 threads on any machine. A drag renders drop shadows within its
|
||||
// budget only with `threads` above 0.
|
||||
let threaded = || {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = 3;
|
||||
r
|
||||
};
|
||||
let fresh = threaded().render(&d, 1600, 1000, view, &still).pixels;
|
||||
let mut r = threaded();
|
||||
let _ = r.render(&base, 1600, 1000, view, &still);
|
||||
assert_eq!(r.stats.previewed, 0);
|
||||
let dragged = r.render(&d, 1600, 1000, view, &drag).pixels;
|
||||
@@ -2835,14 +2881,14 @@ mod tests {
|
||||
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 mut r = threaded();
|
||||
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");
|
||||
assert!(after == threaded().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
|
||||
|
||||
@@ -130,7 +130,9 @@ fn inks(c: &Color) -> [f32; 4] {
|
||||
let cms = cms::active();
|
||||
SEPARATED.with(|s| {
|
||||
let (owner, seen) = &mut *s.borrow_mut();
|
||||
if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 4096 {
|
||||
// Large enough for the many colours of gradient meshes and blends, which otherwise cleared
|
||||
// it over and over and separated every colour through the profile again.
|
||||
if !owner.as_ref().is_some_and(|o| Arc::ptr_eq(o, &cms)) || seen.len() >= 1 << 16 {
|
||||
seen.clear();
|
||||
*owner = Some(cms.clone());
|
||||
}
|
||||
|
||||
+229
-3
@@ -17,7 +17,7 @@ use std::sync::{Arc, Weak};
|
||||
use vectorcraft_doc::live::{GradientMesh, MeshQuad};
|
||||
use vectorcraft_doc::placed_document::ReadDocument;
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, Vec2};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Vec2};
|
||||
use vello_cpu::RenderContext;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
@@ -172,6 +172,174 @@ fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize {
|
||||
((per / 5.0).ceil() as usize).clamp(2, 32)
|
||||
}
|
||||
|
||||
/// The most pixels a gradient mesh is rasterized into at once ([`draw_mesh_raster`]); a larger
|
||||
/// one is drawn quad by quad. The buffer is clipped to the frame; at 4 MP it holds at most 16 MB
|
||||
/// of pixels per mesh and frame.
|
||||
const MESH_RASTER_MAX_PIXELS: usize = 4 << 20;
|
||||
|
||||
/// Draw a tessellated gradient mesh as one image: its quads are filled into a pixel buffer of the
|
||||
/// mesh's on-screen box by scanline (each pixel takes the quad its centre is in, so neighbouring
|
||||
/// quads meet without seams or overlap), and the buffer is drawn through the mesh's outline, which
|
||||
/// antialiases the outer edge. The outline is grown by half a device pixel ([`grown_outline`]), as
|
||||
/// the quads drawn one by one are: a page of meshes laid edge to edge would otherwise show the
|
||||
/// background through each shared, antialiased edge. Drawing every quad as a path of its own costs a
|
||||
/// path's setup per quad: a page of thousands of meshes made millions of them at print size (minutes), where this
|
||||
/// costs about one write per covered pixel. → false (nothing drawn) when the box is too large for
|
||||
/// one buffer, so the caller draws the quads.
|
||||
fn draw_mesh_raster(ctx: &mut RenderContext, f: &Frame, m: &GradientMesh, quads: &[MeshQuad], alpha: f32) -> bool {
|
||||
let outline = m.outline().to_bezpath();
|
||||
let dev = f.view.transform_rect_bbox(vectorcraft_geom::Shape::bounding_box(&outline));
|
||||
if ![dev.x0, dev.y0, dev.x1, dev.y1].iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let (cw, ch) = (f64::from(ctx.width()), f64::from(ctx.height()));
|
||||
let (x0, y0) = ((dev.x0.floor() - 2.0).max(0.0), (dev.y0.floor() - 2.0).max(0.0));
|
||||
let (x1, y1) = ((dev.x1.ceil() + 2.0).min(cw), (dev.y1.ceil() + 2.0).min(ch));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
// Off the context: nothing to draw.
|
||||
return true;
|
||||
}
|
||||
let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize);
|
||||
if w * h > MESH_RASTER_MAX_PIXELS || w > usize::from(u16::MAX) || h > usize::from(u16::MAX) {
|
||||
return false;
|
||||
}
|
||||
let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 };
|
||||
let mut px = vec![clear; w * h];
|
||||
let mut filled = vec![false; w * h];
|
||||
let to_buffer = Affine::translate((-x0, -y0)) * f.view;
|
||||
for q in quads {
|
||||
// A fully transparent quad still claims its pixels, so the edge fill below leaves them clear.
|
||||
let c = if q.opacity > 0.0 { f.ink.color(&q.color, q.opacity * alpha).premultiply().to_rgba8() } else { clear };
|
||||
fill_quad(&mut px, &mut filled, w, h, q.pts.map(|p| to_buffer * p), c);
|
||||
}
|
||||
// Twice: the grown edge samples pixels up to about a pixel and a half out from the quads.
|
||||
let filled = spread_to_edges(&mut px, filled, w, h);
|
||||
spread_to_edges(&mut px, filled, w, h);
|
||||
let pm = Arc::new(vello_cpu::Pixmap::from_parts(px, w as u16, h as u16));
|
||||
ctx.set_transform(Affine::translate((x0, y0)));
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
|
||||
ctx.fill_path(&grown_outline(&(Affine::translate((-x0, -y0)) * f.view * outline), 0.5));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
true
|
||||
}
|
||||
|
||||
/// `path` (device pixels) flattened and each closed subpath moved out by `grow` along its edges'
|
||||
/// normals (mitred, the mitre capped at twice `grow`), so one fill covers the outline and a band
|
||||
/// around it once: filling the outline and stroking it as well would paint a translucent mesh's
|
||||
/// edge twice, a darker line.
|
||||
fn grown_outline(path: &BezPath, grow: f64) -> BezPath {
|
||||
let mut rings: Vec<Vec<Point>> = Vec::new();
|
||||
vectorcraft_geom::kurbo::flatten(path, 0.1, |el| match el {
|
||||
vectorcraft_geom::PathEl::MoveTo(p) => rings.push(vec![p]),
|
||||
vectorcraft_geom::PathEl::LineTo(p) => {
|
||||
if let Some(r) = rings.last_mut()
|
||||
&& r.last().is_none_or(|q| (*q - p).hypot() > 1e-9)
|
||||
{
|
||||
r.push(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
let mut out = BezPath::new();
|
||||
for mut r in rings {
|
||||
if r.len() > 1 && r.first().zip(r.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 1e-9) {
|
||||
r.pop();
|
||||
}
|
||||
let n = r.len();
|
||||
if n < 3 {
|
||||
continue;
|
||||
}
|
||||
// (d.y, -d.x) points out of a ring with a positive shoelace sum (clockwise on a y-down screen).
|
||||
let area: f64 = (0..n).map(|i| r[i].to_vec2().cross(r[(i + 1) % n].to_vec2())).sum();
|
||||
let side = if area > 0.0 { 1.0 } else { -1.0 };
|
||||
let normal = |a: Point, b: Point| {
|
||||
let d = b - a;
|
||||
let l = d.hypot();
|
||||
if l > 1e-12 { Vec2::new(d.y, -d.x) * (side / l) } else { Vec2::ZERO }
|
||||
};
|
||||
for i in 0..n {
|
||||
let (prev, p, next) = (r[(i + n - 1) % n], r[i], r[(i + 1) % n]);
|
||||
let (a, b) = (normal(prev, p), normal(p, next));
|
||||
let sum = a + b;
|
||||
let k = 1.0 + a.dot(b);
|
||||
let mut d = if k > 1e-6 { sum * (grow / k) } else { a * grow };
|
||||
if d.hypot() > 2.0 * grow {
|
||||
d = d * (2.0 * grow / d.hypot());
|
||||
}
|
||||
let q = p + d;
|
||||
if i == 0 {
|
||||
out.move_to(q);
|
||||
} else {
|
||||
out.line_to(q);
|
||||
}
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Fill the pixels of the `w`×`h` buffer whose centres are inside quad `p` (buffer pixels, even-odd
|
||||
/// so a twisted quad is filled as drawn) with `c`, and mark them `filled`.
|
||||
fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: usize, h: usize, p: [Point; 4], c: vello_cpu::color::PremulRgba8) {
|
||||
if !p.iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (min_y, max_y) = p.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), q| (a.min(q.y), b.max(q.y)));
|
||||
let first = (min_y - 0.5).ceil().max(0.0) as usize;
|
||||
let last = ((max_y - 0.5).ceil().max(0.0) as usize).min(h);
|
||||
for y in first..last {
|
||||
let yc = y as f64 + 0.5;
|
||||
let mut xs = [0.0f64; 4];
|
||||
let mut n = 0;
|
||||
for i in 0..4 {
|
||||
let (a, b) = (p[i], p[(i + 1) % 4]);
|
||||
if (a.y <= yc) != (b.y <= yc) {
|
||||
xs[n] = a.x + (yc - a.y) * (b.x - a.x) / (b.y - a.y);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
let xs = &mut xs[..n];
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for [a, b] in xs.as_chunks::<2>().0 {
|
||||
let from = (a - 0.5).ceil().clamp(0.0, w as f64) as usize;
|
||||
let to = (b - 0.5).ceil().clamp(0.0, w as f64) as usize;
|
||||
let row = y * w;
|
||||
for (d, k) in
|
||||
px.get_mut(row + from..row + to).unwrap_or_default().iter_mut().zip(filled.get_mut(row + from..row + to).unwrap_or_default())
|
||||
{
|
||||
*d = c;
|
||||
*k = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give each pixel no quad covered the colour of a covered neighbour: the outline's antialiased
|
||||
/// edge samples pixels whose centres fall just outside the quads, which would otherwise fade it.
|
||||
/// → the pixels covered now (the given ones and those just given a colour).
|
||||
fn spread_to_edges(px: &mut [vello_cpu::color::PremulRgba8], filled: Vec<bool>, w: usize, h: usize) -> Vec<bool> {
|
||||
let mut now = filled.clone();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if filled.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let near = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||||
if let Some(j) = near.into_iter().flatten().find(|j| filled.get(*j).copied().unwrap_or(false))
|
||||
&& let Some(c) = px.get(j).copied()
|
||||
&& let Some(d) = px.get_mut(i)
|
||||
{
|
||||
*d = c;
|
||||
if let Some(k) = now.get_mut(i) {
|
||||
*k = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
now
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// The [`resources_key`] of `n` in `doc`, with the frame's [`DocInputs`] ([`Found`]): `n` is a
|
||||
/// live node, or an object with effects whose art ([`Renderer::fx_art`]) can be made from
|
||||
@@ -286,10 +454,13 @@ impl Renderer {
|
||||
let Some(b) = m.bounds() else { return };
|
||||
let level = mesh_level(m, b.width().max(b.height()) / f.px.max(1e-12));
|
||||
let quads = self.mesh_quads(f.doc, a, m, level, cache);
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
let grow = f.px * 0.5;
|
||||
let alpha = self.alpha;
|
||||
if draw_mesh_raster(ctx, f, m, &quads, alpha) {
|
||||
ctx.set_transform(f.view);
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(f.view);
|
||||
// Where a patch narrows to slivers, many quads share each pixel, each covering a little of it: their edges
|
||||
// composite to part coverage, and hard edges drop each one. An opaque patch is backed by its own outline in
|
||||
// its middle colour, one path that covers fully, so the background can't show through.
|
||||
@@ -310,6 +481,7 @@ impl Renderer {
|
||||
ctx.fill_path(&bp);
|
||||
}
|
||||
}
|
||||
let grow = f.px * 0.5;
|
||||
for q in quads.iter() {
|
||||
if q.opacity <= 0.0 {
|
||||
continue;
|
||||
@@ -382,6 +554,60 @@ mod tests {
|
||||
(0..120).flat_map(|y| (0..440).map(move |x| (x, y))).filter(|&(x, y)| smooth.pixel(x, y)[3] >= 200 && hard.pixel(x, y)[3] == 0).count();
|
||||
assert!(holes < 60, "{holes} pixels the smooth render covers are empty with hard edges");
|
||||
}
|
||||
|
||||
/// Quads that share edges fill every pixel of their union once: no gap, no overlap.
|
||||
#[test]
|
||||
fn quads_meeting_edge_to_edge_fill_their_union_once() {
|
||||
let (w, h) = (20, 20);
|
||||
let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 };
|
||||
let red = vello_cpu::color::PremulRgba8 { r: 255, g: 0, b: 0, a: 255 };
|
||||
let mut px = vec![clear; w * h];
|
||||
let mut count = vec![0u8; w * h];
|
||||
// A 2×2 grid over (2.3, 2.7)–(17.6, 16.2) with an inner corner off the pixel grid.
|
||||
let xs = [2.3, 9.71, 17.6];
|
||||
let ys = [2.7, 8.35, 16.2];
|
||||
for j in 0..2 {
|
||||
for i in 0..2 {
|
||||
let pts = [Point::new(xs[i], ys[j]), Point::new(xs[i + 1], ys[j]), Point::new(xs[i + 1], ys[j + 1]), Point::new(xs[i], ys[j + 1])];
|
||||
let mut mine = vec![false; w * h];
|
||||
fill_quad(&mut px, &mut mine, w, h, pts, red);
|
||||
for (c, m) in count.iter_mut().zip(&mine) {
|
||||
*c += u8::from(*m);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (cx, cy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let inside = (2.3..17.6).contains(&cx) && (2.7..16.2).contains(&cy);
|
||||
assert_eq!(count[y * w + x], u8::from(inside), "pixel {x},{y}");
|
||||
}
|
||||
}
|
||||
let filled: Vec<bool> = count.iter().map(|c| *c > 0).collect();
|
||||
let filled = spread_to_edges(&mut px, filled, w, h);
|
||||
assert_eq!(px[2 * w + 2].a, 255, "the pixel outside the corner takes its neighbour's colour");
|
||||
assert_eq!(px[2 * w + 1].a, 0, "one pass reaches one pixel out");
|
||||
spread_to_edges(&mut px, filled, w, h);
|
||||
assert_eq!(px[2 * w + 1].a, 255, "a second pass reaches the next one, which the grown outline samples");
|
||||
}
|
||||
|
||||
/// A grown square covers half a pixel more on every side, whichever way it winds.
|
||||
#[test]
|
||||
fn an_outline_grows_by_half_a_pixel_both_ways_round() {
|
||||
for square in [vectorcraft_geom::shapes::rectangle(Rect::new(2.0, 3.0, 10.0, 8.0)).to_bezpath(), {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((2.0, 3.0));
|
||||
p.line_to((2.0, 8.0));
|
||||
p.line_to((10.0, 8.0));
|
||||
p.line_to((10.0, 3.0));
|
||||
p.close_path();
|
||||
p
|
||||
}] {
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&grown_outline(&square, 0.5));
|
||||
assert!((b.x0 - 1.5).abs() < 1e-6 && (b.y0 - 2.5).abs() < 1e-6, "{b:?}");
|
||||
assert!((b.x1 - 10.5).abs() < 1e-6 && (b.y1 - 8.5).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
}
|
||||
use vectorcraft_doc::{Appearance, EnvelopeKind, Symbol};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
|
||||
@@ -2008,8 +2008,8 @@ impl Writer<'_> {
|
||||
// After a tab, an inline graphic or a justified word space, and before a character that a
|
||||
// reader would draw elsewhere, the next segment is placed.
|
||||
place = seg.brk || (!fewer && !base.anchored && positioned);
|
||||
if (st.h_scale - st.v_scale).abs() > 1e-9 {
|
||||
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
|
||||
if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 {
|
||||
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale)));
|
||||
}
|
||||
if st.rotation != 0.0 {
|
||||
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
|
||||
@@ -2241,6 +2241,9 @@ struct Segment {
|
||||
/// The next segment needs its own position (after a tab, a word space of a justified line, or
|
||||
/// the next segment starts with a character the layout draws elsewhere than a reader would).
|
||||
brk: bool,
|
||||
/// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to
|
||||
/// (`textLength`); 1 for other segments.
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
/// How far a reader may draw a character from where the layout draws it before the character is
|
||||
@@ -2263,7 +2266,10 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
let style = t.para_at(para);
|
||||
let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
// Each cluster's advance and drawing offset as a reader gives them, by its byte offset.
|
||||
let reader = if split_words || (!fewer && !style.mojikumi.is_none()) {
|
||||
// Justification…'s Desired word and letter spacing and glyph scaling move characters from
|
||||
// where a reader draws them, on every line.
|
||||
let spaced = !fewer && !style.justification.desired_is_default();
|
||||
let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) {
|
||||
Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true)))
|
||||
} else {
|
||||
None
|
||||
@@ -2316,7 +2322,9 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
|
||||
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
|
||||
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
|
||||
let fresh = std::mem::take(&mut after_inline);
|
||||
// A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width.
|
||||
let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 };
|
||||
let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0);
|
||||
// The layout draws the character elsewhere than a reader continuing the line would.
|
||||
let moved = reader.is_some()
|
||||
&& segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE);
|
||||
@@ -2332,7 +2340,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
last.brk |= moved;
|
||||
end_segment(last, tail, t, lay, &mut unkerned);
|
||||
}
|
||||
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk });
|
||||
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale });
|
||||
}
|
||||
}
|
||||
tail = set.map(|_| (g.byte, advance));
|
||||
@@ -2348,7 +2356,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
lines
|
||||
}
|
||||
|
||||
/// Ends segment `seg` of horizontally scaled type, which a reader stretches to its width
|
||||
/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width
|
||||
/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance
|
||||
/// a reader gives it at the end of a `<tspan>`, without the font's kerning against the character
|
||||
/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them.
|
||||
@@ -2360,7 +2368,7 @@ fn end_segment(
|
||||
unkerned: &mut Option<HashMap<usize, (f64, f64)>>,
|
||||
) {
|
||||
let Some((byte, advance)) = tail else { return };
|
||||
if t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) {
|
||||
if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) {
|
||||
return;
|
||||
}
|
||||
if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) {
|
||||
@@ -2377,6 +2385,8 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
|
||||
let mut set = TextObject { kind: TextKind::Point, ..t.clone() };
|
||||
for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) {
|
||||
p.mojikumi = vectorcraft_doc::Mojikumi::None;
|
||||
// A reader spaces words and letters as the font does.
|
||||
p.justification = vectorcraft_doc::JustificationOptions::default();
|
||||
}
|
||||
if !kerning {
|
||||
for r in &mut set.runs {
|
||||
|
||||
@@ -79,25 +79,34 @@ fn percent_decoded(s: &str) -> String {
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// Where href `h` points: the path its link keeps, and the file to read when it 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, made absolute against the working directory when it isn't; without
|
||||
/// a folder, only absolute paths are read (see `craft_docpath`). The link keeps the path as
|
||||
/// written (decoded) when the folder does not change the path it resolves to, and the path found
|
||||
/// when it does (a relative path, or on Windows a path rooted without a drive); the link of a
|
||||
/// `file:` URL keeps the path it names.
|
||||
fn target(h: &str, folder: Option<&str>) -> (String, Option<String>) {
|
||||
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);
|
||||
return (stored, None);
|
||||
}
|
||||
// Resolved as a document inside `folder`: only the folder is used.
|
||||
let document = folder.map(|f| Path::new(f).join("-"));
|
||||
let document = folder.map(|f| {
|
||||
let f = Path::new(f);
|
||||
if f.is_absolute() { f.to_path_buf() } else { std::path::absolute(f).unwrap_or_else(|_| f.to_path_buf()) }.join("-")
|
||||
});
|
||||
match craft_docpath::resolve(&stored, document.as_deref()) {
|
||||
Ok(path) => {
|
||||
let local = path.is_absolute();
|
||||
(path.to_string_lossy().into_owned(), local)
|
||||
let file = path.is_absolute().then(|| path.to_string_lossy().into_owned());
|
||||
let as_written = !file_url && craft_docpath::resolve(&stored, None).as_ref() == Ok(&path);
|
||||
let link = if as_written { stored } else { path.to_string_lossy().into_owned() };
|
||||
(link, file)
|
||||
}
|
||||
Err(_) => (stored, false),
|
||||
Err(_) => (stored, None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,8 +125,8 @@ impl Files {
|
||||
for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") {
|
||||
let Some(h) = href(n).filter(|h| !h.trim_start().starts_with("data:")) else { continue };
|
||||
if !files.sources.contains_key(h) {
|
||||
let (path, local) = target(h, opts.folder);
|
||||
let read = if local { opts.read.and_then(|read| read(&path)) } else { None };
|
||||
let (path, file) = target(h, opts.folder);
|
||||
let read = file.and_then(|file| opts.read.and_then(|read| read(&file)));
|
||||
let source = match read {
|
||||
Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"),
|
||||
None => Err("not found"),
|
||||
@@ -229,17 +238,52 @@ mod tests {
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
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));
|
||||
let found = format!("{art}{sep}photo one.png");
|
||||
assert_eq!(target("photo%20one.png", Some(art)), (found.clone(), Some(found)));
|
||||
assert!(target("photo.png", None).1.is_none());
|
||||
assert_eq!(target("file:///C:/art/a.png", None), (r"C:\art\a.png".into(), cfg!(windows).then(|| r"C:\art\a.png".into())));
|
||||
assert_eq!(target("https://example.com/a.png", Some(art)), ("https://example.com/a.png".into(), None));
|
||||
#[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));
|
||||
assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), Some("/abs/photo.png".into())));
|
||||
assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), Some("/home/a.png".into())));
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a folder, a link keeps its path as the href spells it, on every system; with one, a
|
||||
/// drive path keeps it too. The file to read is the absolute path that `craft_docpath`
|
||||
/// resolves on the running system.
|
||||
#[test]
|
||||
fn links_keep_hrefs_as_written() {
|
||||
let art = std::env::temp_dir().join("art");
|
||||
let art = art.to_str();
|
||||
for h in ["/abs/photo.png", r"\abs\photo.png", "C:/art/a.png", r"C:\art\a.png", "img/a.png"] {
|
||||
assert_eq!(target(h, None).0, h);
|
||||
}
|
||||
for h in ["C:/art/a.png", r"C:\art\a.png"] {
|
||||
assert_eq!(target(h, art).0, h);
|
||||
}
|
||||
assert_eq!(target("/abs/photo%20one.png", None).0, "/abs/photo one.png", "decoded");
|
||||
// The SVG's folder gives a rooted path its drive on Windows, and the link keeps that.
|
||||
let (link, file) = target("/abs/photo.png", art);
|
||||
let file = file.unwrap();
|
||||
assert!(Path::new(&file).is_absolute() && Path::new(&file).ends_with("abs/photo.png"), "{file}");
|
||||
assert_eq!(link, if cfg!(windows) { file } else { "/abs/photo.png".into() });
|
||||
}
|
||||
|
||||
/// A folder that isn't absolute (`/tmp` on Windows, `art`) is taken from the working
|
||||
/// directory, so the files its SVG links to are read.
|
||||
#[test]
|
||||
fn a_folder_that_is_not_absolute_is_made_absolute() {
|
||||
let here = std::env::current_dir().unwrap();
|
||||
let (link, file) = target("photo.png", Some("art"));
|
||||
assert_eq!(file.as_deref().map(Path::new), Some(here.join("art").join("photo.png").as_path()));
|
||||
assert_eq!(link, file.unwrap());
|
||||
let (_, file) = target("photo.png", Some("/tmp"));
|
||||
let file = file.unwrap();
|
||||
assert!(Path::new(&file).is_absolute() && file.ends_with("photo.png"), "{file}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hrefs_to_other_computers_are_not_read() {
|
||||
let art = std::env::temp_dir().join("art");
|
||||
@@ -250,8 +294,8 @@ mod tests {
|
||||
"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}");
|
||||
assert!(target(h, art.to_str()).1.is_none(), "{h}");
|
||||
assert!(target(h, None).1.is_none(), "{h}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -524,7 +524,8 @@ pub type ReadFile<'a> = &'a (dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::L
|
||||
/// warning), so it can be relinked.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ImportOptions<'a> {
|
||||
/// The folder relative links are found in: the SVG file's own. Without it they can't be read.
|
||||
/// The folder relative links are found in: the SVG file's own (taken from the working
|
||||
/// directory when it isn't absolute). Without it they can't be read.
|
||||
pub folder: Option<&'a str>,
|
||||
/// Reads a linked file. Without it no file is read.
|
||||
pub read: Option<ReadFile<'a>>,
|
||||
|
||||
@@ -18,10 +18,16 @@ fn style(size: f64) -> CharStyle {
|
||||
|
||||
/// Area type `w` wide at (20, 20) in a 300 × 200 document.
|
||||
fn area_doc(runs: Vec<TextRun>, w: f64, justify: Justify) -> Document {
|
||||
area_doc_justified(runs, w, justify, vectorcraft_doc::JustificationOptions::default())
|
||||
}
|
||||
|
||||
/// [`area_doc`] with the paragraph's Justification options `j`.
|
||||
fn area_doc_justified(runs: Vec<TextRun>, w: f64, justify: Justify, j: vectorcraft_doc::JustificationOptions) -> Document {
|
||||
let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0));
|
||||
t.runs = runs;
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, 160.0)) };
|
||||
t.para.justify = justify;
|
||||
t.para.justification = j;
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
@@ -252,6 +258,32 @@ fn justify_all_lines_keep_their_letter_gaps() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Justification…: Desired word and letter spacing and glyph scaling, and the ranges a justified
|
||||
/// line uses, are written so a viewer draws each character where and as wide as the canvas does.
|
||||
#[test]
|
||||
fn justification_spacing_and_glyph_scaling_are_drawn_in_place() {
|
||||
use vectorcraft_doc::JustificationOptions;
|
||||
let text = "Typography arranges type to make written language legible and readable";
|
||||
for (justify, j) in [
|
||||
(
|
||||
Justify::Left,
|
||||
JustificationOptions { word_spacing: [0.0, 180.0, 200.0], letter_spacing: [0.0, 10.0, 10.0], ..JustificationOptions::default() },
|
||||
),
|
||||
(Justify::Left, JustificationOptions { glyph_scaling: [50.0, 120.0, 200.0], ..JustificationOptions::default() }),
|
||||
(
|
||||
Justify::JustifyLeft,
|
||||
JustificationOptions {
|
||||
word_spacing: [100.0, 100.0, 100.0],
|
||||
letter_spacing: [0.0, 0.0, 5.0],
|
||||
glyph_scaling: [95.0, 100.0, 110.0],
|
||||
..JustificationOptions::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_drawn_in_place(&area_doc_justified(vec![run(text, style(12.0))], 160.0, justify, j));
|
||||
}
|
||||
}
|
||||
|
||||
/// Justified and Mojikumi lines whose characters a reader draws where the layout does are written
|
||||
/// as before: a segment per style run, and per word on a justified line.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the
|
||||
//! EPS, `.ai` and PDF files that carry it, for import tests. Nothing here comes from files another
|
||||
//! app wrote, except the key names that [`pdf`] uses.
|
||||
//! app wrote, except the key names that [`pdf`] uses and the XML node keys of [`variable_library`].
|
||||
|
||||
/// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop
|
||||
/// marks are the page), `body` (setup and layers), the trailer.
|
||||
@@ -32,6 +32,43 @@ pub fn named(name: &str) -> String {
|
||||
format!("%_/ArtDictionary :\n%_/XMLUID : ({}) ; (AI10_ArtUID) ,\n%_;\n%_\n", name.replace(' ', "_"))
|
||||
}
|
||||
|
||||
/// The document data with a variable library: a `/Document` dictionary whose `Recorded` dictionary
|
||||
/// holds the library's XML document as XML nodes (`/XMLNode`) under `#document`. The library has
|
||||
/// one variable set, the text variables `variables` and the data sets `data_sets`, each with a `p`
|
||||
/// element for every variable.
|
||||
pub fn variable_library(variables: &[&str], data_sets: &[&str]) -> String {
|
||||
// A node: its name, value and type (1 an element, 2 an attribute, 3 text, 9 the document), its
|
||||
// children and its attributes.
|
||||
fn node(kind: u8, name: &str, value: &str, attributes: &[(&str, &str)], children: &[String]) -> String {
|
||||
let attributes: String = attributes.iter().map(|(k, v)| node(2, k, v, &[], &[node(3, "#text", v, &[], &[])])).collect();
|
||||
format!(
|
||||
"%_/XMLNode :\n%_({name}) /String (xmlnode-nodename) ,\n%_({value}) /String (xmlnode-nodevalue) ,\n%_{kind} /Int (xmlnode-nodetype) ,\n\
|
||||
%_/Array :\n{}%_; (xmlnode-children) ,\n%_/Array :\n{attributes}%_; (xmlnode-attributes) ,\n%_;\n",
|
||||
children.concat()
|
||||
)
|
||||
}
|
||||
let element = |name: &str, attributes: &[(&str, &str)], children: &[String]| node(1, name, "", attributes, children);
|
||||
let declared: Vec<String> = variables.iter().map(|v| element("variable", &[("varName", v), ("trait", "textcontent")], &[])).collect();
|
||||
let sets: Vec<String> = data_sets
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let values: Vec<String> =
|
||||
variables.iter().map(|v| element(v, &[], &[element("p", &[], &[node(3, "#text", &format!("{d} {v}"), &[], &[])])])).collect();
|
||||
element("v:sampleDataSet", &[("dataSetName", d)], &values)
|
||||
})
|
||||
.collect();
|
||||
let set = element(
|
||||
"variableSet",
|
||||
&[("varSetName", "binding1"), ("locked", "none")],
|
||||
&[element("variables", &[], &declared), element("v:sampleDataSets", &[("xmlns", "&ns_custom;"), ("xmlns:v", "&ns_vars;")], &sets)],
|
||||
);
|
||||
let library = element("metadata", &[], &[element("variableSets", &[("xmlns", "&ns_vars;")], &[set])]);
|
||||
format!(
|
||||
"%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n{}%_; (#document) ,\n%_; /Recorded ,\n%_;\n%AI9_EndDocumentData\n",
|
||||
node(9, "#document", "", &[], &[library])
|
||||
)
|
||||
}
|
||||
|
||||
/// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows:
|
||||
/// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group
|
||||
/// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A
|
||||
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user